From a6e482484eb8c633caf26e93ebb0774ffe98eac7 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 26 May 2023 11:30:40 +0100 Subject: [PATCH 001/429] Allow for knob values to be validated against multiple values. --- .../plugins/publish/validate_write_nodes.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index aeecea655f..2a925fbeff 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -1,3 +1,5 @@ +from collections import defaultdict + import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context from openpype.hosts.nuke.api.lib import ( @@ -87,6 +89,11 @@ class ValidateNukeWriteNode( correct_data )) + # Collect key values of same type in a list. + values_by_name = defaultdict(list) + for knob_data in correct_data["knobs"]: + values_by_name[knob_data["name"]].append(knob_data["value"]) + for knob_data in correct_data["knobs"]: knob_type = knob_data["type"] self.log.debug("__ knob_type: {}".format( @@ -105,28 +112,33 @@ class ValidateNukeWriteNode( ) key = knob_data["name"] - value = knob_data["value"] + values = values_by_name[key] node_value = write_node[key].value() # fix type differences - if type(node_value) in (int, float): - try: - if isinstance(value, list): - value = color_gui_to_int(value) - else: - value = float(value) - node_value = float(node_value) - except ValueError: - value = str(value) - else: - value = str(value) - node_value = str(node_value) + fixed_values = [] + for value in values: + if type(node_value) in (int, float): + try: - self.log.debug("__ key: {} | value: {}".format( - key, value + if isinstance(value, list): + value = color_gui_to_int(value) + else: + value = float(value) + node_value = float(node_value) + except ValueError: + value = str(value) + else: + value = str(value) + node_value = str(node_value) + + fixed_values.append(value) + + self.log.debug("__ key: {} | values: {}".format( + key, fixed_values )) if ( - node_value != value + node_value not in fixed_values and key != "file" and key != "tile_color" ): From 6ee7780972d2c37ccbf0279e5b7c21471afdba97 Mon Sep 17 00:00:00 2001 From: skacmazbelhaine Date: Tue, 6 Jun 2023 15:51:20 +0200 Subject: [PATCH 002/429] Update local settings --- website/docs/admin_settings_local.md | 27 +++++++++++++++++- .../assets/settings/settings_local_02.png | Bin 0 -> 11181 bytes 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 website/docs/assets/settings/settings_local_02.png diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index b254beb53b..214615f442 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -11,18 +11,43 @@ OpenPype stores some of it's settings and configuration in local file system. Th **Local Settings** GUI can be started from the tray menu. -![Local Settings](assets/settings/settings_local.png) +![Local Settings](assets/settings/settings_local_02.png) ## Categories + + ### OpenPype Mongo URL +The **Mongo URL** is the database URL given by your Studio. More details [here](https://openpype.io/docs/artist_getting_started#mongodb). + +
### General +**OpenPype Username** : enter your username (it can also take by default the computer session username). It signs your actions on **OpenPype**. + +
+ +**Admin permissions** : on checked, no need to enter a password (if defined) to access to the **Admin** section. + +
### Experimental tools +Futur version of existing tools or new ones. + +
+ +### Environments +**Environments** data of each software and there extra in-house needed to be loaded correctly. More details [here](https://openpype.io/docs/pype2/admin_config/#environments). + +
### Applications +Location of the softwares and there versions. More details [here](https://openpype.io/docs/admin_settings_system/#applications). + +
### Project Settings +The **Project Settings** allows to determine the root folder. More details [here](https://openpype.io/docs/module_site_sync/#project-settings). + diff --git a/website/docs/assets/settings/settings_local_02.png b/website/docs/assets/settings/settings_local_02.png new file mode 100644 index 0000000000000000000000000000000000000000..725c3327472cae5dc6271586aa4a1de2495104ca GIT binary patch literal 11181 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 T+AsLMj#fcN6 Date: Tue, 6 Jun 2023 15:51:32 +0200 Subject: [PATCH 003/429] Update local settings --- website/docs/admin_settings_local.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index 214615f442..222ee41515 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -36,7 +36,7 @@ Futur version of existing tools or new ones.
-### Environments +### Environments **Environments** data of each software and there extra in-house needed to be loaded correctly. More details [here](https://openpype.io/docs/pype2/admin_config/#environments).
@@ -48,6 +48,3 @@ Location of the softwares and there versions. More details [here](https://openpy ### Project Settings The **Project Settings** allows to determine the root folder. More details [here](https://openpype.io/docs/module_site_sync/#project-settings). - - - From ead57a2cafe2d451e99189d7d1aa94678a032033 Mon Sep 17 00:00:00 2001 From: skacmazbelhaine Date: Tue, 6 Jun 2023 16:30:11 +0200 Subject: [PATCH 004/429] Update local settings --- website/docs/admin_settings_local.md | 2 +- .../docs/assets/settings/settings_local.png | Bin 7212 -> 11181 bytes .../assets/settings/settings_local_02.png | Bin 11181 -> 0 bytes 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 website/docs/assets/settings/settings_local_02.png diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index 222ee41515..177c32451b 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -11,7 +11,7 @@ OpenPype stores some of it's settings and configuration in local file system. Th **Local Settings** GUI can be started from the tray menu. -![Local Settings](assets/settings/settings_local_02.png) +![Local Settings](assets/settings/settings_local.png) ## Categories diff --git a/website/docs/assets/settings/settings_local.png b/website/docs/assets/settings/settings_local.png index d2cf1c920d07e98bb53570f6b76ef039e642e741..725c3327472cae5dc6271586aa4a1de2495104ca 100644 GIT binary patch literal 11181 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 T+AsLMj#fcN6V{?TXm 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 Tu$%%u=7ApE)zK(Xvkdw#D_=3L diff --git a/website/docs/assets/settings/settings_local_02.png b/website/docs/assets/settings/settings_local_02.png deleted file mode 100644 index 725c3327472cae5dc6271586aa4a1de2495104ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11181 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 T+AsLMj#fcN6 Date: Wed, 7 Jun 2023 10:57:27 +0200 Subject: [PATCH 005/429] update after comments - local settings --- website/docs/admin_settings_local.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index 177c32451b..99e27731d0 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -18,7 +18,7 @@ OpenPype stores some of it's settings and configuration in local file system. Th ### OpenPype Mongo URL -The **Mongo URL** is the database URL given by your Studio. More details [here](https://openpype.io/docs/artist_getting_started#mongodb). +The **Mongo URL** is the database URL given by your Studio. More details [here](artist_getting_started#mongodb).
@@ -37,14 +37,14 @@ Futur version of existing tools or new ones.
### Environments -**Environments** data of each software and there extra in-house needed to be loaded correctly. More details [here](https://openpype.io/docs/pype2/admin_config/#environments). +Local replacement of the environment data of each software and additional internal data necessary to be loaded correctly.
### Applications -Location of the softwares and there versions. More details [here](https://openpype.io/docs/admin_settings_system/#applications). +Local override of software paths and their versions. More details [here](admin_settings_system/#applications).
### Project Settings -The **Project Settings** allows to determine the root folder. More details [here](https://openpype.io/docs/module_site_sync/#project-settings). +The **Project Settings** allows to determine the root folder. More details [here](module_site_sync/#local-settings). From 85af2013726edb61a76faff9122a28cd1f1749dc Mon Sep 17 00:00:00 2001 From: skacmazbelhaine Date: Thu, 8 Jun 2023 10:16:04 +0200 Subject: [PATCH 006/429] fix link path --- website/docs/admin_settings_local.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index 99e27731d0..33e0e374ce 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -18,7 +18,7 @@ OpenPype stores some of it's settings and configuration in local file system. Th ### OpenPype Mongo URL -The **Mongo URL** is the database URL given by your Studio. More details [here](artist_getting_started#mongodb). +The **Mongo URL** is the database URL given by your Studio. More details [here](artist_getting_started.md#mongodb).
@@ -42,9 +42,9 @@ Local replacement of the environment data of each software and additional intern
### Applications -Local override of software paths and their versions. More details [here](admin_settings_system/#applications). +Local override of software paths and their versions. More details [here](admin_settings_system.md#applications).
### Project Settings -The **Project Settings** allows to determine the root folder. More details [here](module_site_sync/#local-settings). +The **Project Settings** allows to determine the root folder. More details [here](module_site_sync.md#local-settings). From 54bdd152f6b3d67e240d487b264f1a674a75f876 Mon Sep 17 00:00:00 2001 From: skacmazbelhaine Date: Thu, 8 Jun 2023 11:03:19 +0200 Subject: [PATCH 007/429] Correction of line breaks --- website/docs/admin_settings_local.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index 33e0e374ce..142238f14f 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -15,36 +15,22 @@ OpenPype stores some of it's settings and configuration in local file system. Th ## Categories - - ### OpenPype Mongo URL The **Mongo URL** is the database URL given by your Studio. More details [here](artist_getting_started.md#mongodb). -
- ### General **OpenPype Username** : enter your username (it can also take by default the computer session username). It signs your actions on **OpenPype**. -
- **Admin permissions** : on checked, no need to enter a password (if defined) to access to the **Admin** section. -
- ### Experimental tools Futur version of existing tools or new ones. -
- ### Environments Local replacement of the environment data of each software and additional internal data necessary to be loaded correctly. -
- ### Applications Local override of software paths and their versions. More details [here](admin_settings_system.md#applications). -
- ### Project Settings The **Project Settings** allows to determine the root folder. More details [here](module_site_sync.md#local-settings). From aea1adab65ed3707a8963543313e0e6976082046 Mon Sep 17 00:00:00 2001 From: skacmazbelhaine Date: Tue, 13 Jun 2023 17:03:51 +0200 Subject: [PATCH 008/429] update local settings for global settings PR --- website/docs/admin_settings_local.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index 142238f14f..8935b29fb5 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -19,18 +19,16 @@ OpenPype stores some of it's settings and configuration in local file system. Th The **Mongo URL** is the database URL given by your Studio. More details [here](artist_getting_started.md#mongodb). ### General -**OpenPype Username** : enter your username (it can also take by default the computer session username). It signs your actions on **OpenPype**. - -**Admin permissions** : on checked, no need to enter a password (if defined) to access to the **Admin** section. +**OpenPype Username** : enter your username (if not provided, it uses computer session username by default). This username is used to sign your actions on **OpenPype**, for example the "author" on a publish. +**Admin permissions** : When enabled you do not need to enter a password (if defined in Studio Settings) to access to the **Admin** section. ### Experimental tools -Futur version of existing tools or new ones. - +Future version of existing tools or new ones. ### Environments Local replacement of the environment data of each software and additional internal data necessary to be loaded correctly. ### Applications -Local override of software paths and their versions. More details [here](admin_settings_system.md#applications). +Local override of software executable paths for each version. More details [here](admin_settings_system.md#applications). ### Project Settings The **Project Settings** allows to determine the root folder. More details [here](module_site_sync.md#local-settings). From 8dea1724f9d13c1bb11f6840fb73228eb95b7086 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 23 Jun 2023 16:01:24 +0100 Subject: [PATCH 009/429] Check custom staging dir for Maya images folder. --- .../publish/validate_render_image_rule.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index 78bb022785..96a57ee5d2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -1,3 +1,5 @@ +import os + from maya import cmds import pyblish.api @@ -24,8 +26,12 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): def process(self, instance): - required_images_rule = self.get_default_render_image_folder(instance) - current_images_rule = cmds.workspace(fileRuleEntry="images") + required_images_rule = os.path.normpath( + self.get_default_render_image_folder(self, instance) + ) + current_images_rule = os.path.normpath( + cmds.workspace(fileRuleEntry="images") + ) assert current_images_rule == required_images_rule, ( "Invalid workspace `images` file rule value: '{}'. " @@ -37,7 +43,9 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - required_images_rule = cls.get_default_render_image_folder(instance) + required_images_rule = cls.get_default_render_image_folder( + cls, instance + ) current_images_rule = cmds.workspace(fileRuleEntry="images") if current_images_rule != required_images_rule: @@ -45,7 +53,16 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): cmds.workspace(saveWorkspace=True) @staticmethod - def get_default_render_image_folder(instance): + def get_default_render_image_folder(cls, instance): + staging_dir = instance.data.get("stagingDir") + if staging_dir: + cls.log.debug( + "Staging dir found: \"{}\". Ignoring setting from " + "`project_settings/maya/RenderSettings/" + "default_render_image_folder`.".format(staging_dir) + ) + return staging_dir + return instance.context.data.get('project_settings')\ .get('maya') \ .get('RenderSettings') \ From 7dba2378844a2268ed1f8dc7f3cc6a8a3d47d02e Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 23 Jun 2023 16:21:45 +0100 Subject: [PATCH 010/429] Docs and setting note. --- .../schemas/schema_maya_render_settings.json | 2 +- .../project_settings/settings_project_global.md | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json index 636dfa114c..fc4e750e3b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json @@ -12,7 +12,7 @@ { "type": "text", "key": "default_render_image_folder", - "label": "Default render image folder" + "label": "Default render image folder. This setting can be\noverwritten by custom staging directory profile;\n\"project_settings/global/tools/publish\n/custom_staging_dir_profiles\"." }, { "type": "boolean", diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 5ddf247d98..e0481a8717 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -192,7 +192,7 @@ A profile may generate multiple outputs from a single input. Each output must de - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. - + ### Extract Burnin Plugin is responsible for adding burnins into review representations. @@ -226,13 +226,13 @@ A burnin profile may set multiple burnin outputs from one input. The burnin's na | **Bottom Centered** | Bottom center content. | str | "{username}" | | **Bottom Right** | Bottom right corner content. | str | "{frame_start}-{current_frame}-{frame_end}" | -Each burnin profile can be configured with additional family filtering and can -add additional tags to the burnin representation, these can be configured under +Each burnin profile can be configured with additional family filtering and can +add additional tags to the burnin representation, these can be configured under the profile's **Additional filtering** section. :::note Filename suffix -The filename suffix is appended to filename of the source representation. For -example, if the source representation has suffix **"h264"** and the burnin +The filename suffix is appended to filename of the source representation. For +example, if the source representation has suffix **"h264"** and the burnin suffix is **"client"** then the final suffix is **"h264_client"**. ::: @@ -343,6 +343,10 @@ One of the key advantages of this feature is that it allows users to choose the In some cases, these DCCs (Nuke, Houdini, Maya) automatically add a rendering path during the creation stage, which is then used in publishing. Creators and extractors of such DCCs need to use these profiles to fill paths in DCC's nodes to use this functionality. +:::note +Maya's setting `project_settings/maya/RenderSettings/default_render_image_folder` is be overwritten by the custom staging dir. +::: + The custom staging folder uses a path template configured in `project_anatomy/templates/others` with `transient` being a default example path that could be used. The template requires a 'folder' key for it to be usable as custom staging folder. ##### Known issues From 49dc54c64767c62c7c71718cf047a489886f84ab Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Sun, 25 Jun 2023 15:37:52 +0100 Subject: [PATCH 011/429] Fix getting render paths. --- openpype/hosts/maya/plugins/publish/collect_render.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index babd494758..ac318dfbf7 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -201,10 +201,10 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # append full path aov_dict = {} - default_render_file = context.data.get('project_settings')\ - .get('maya')\ - .get('RenderSettings')\ - .get('default_render_image_folder') or "" + image_directory = os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="images") + ) # replace relative paths with absolute. Render products are # returned as list of dictionaries. publish_meta_path = None @@ -212,8 +212,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): full_paths = [] aov_first_key = list(aov.keys())[0] for file in aov[aov_first_key]: - full_path = os.path.join(workspace, default_render_file, - file) + full_path = os.path.join(image_directory, file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) publish_meta_path = os.path.dirname(full_path) From 96d796c4f5bdb51bd0461ad0f9a984019e48664c Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Sun, 25 Jun 2023 15:38:26 +0100 Subject: [PATCH 012/429] Account for custom staging directory persistentcy. --- .../hosts/maya/plugins/publish/extract_pointcache.py | 3 ++- .../hosts/maya/plugins/publish/extract_proxy_abc.py | 3 ++- .../hosts/maya/plugins/publish/extract_thumbnail.py | 3 ++- .../deadline/plugins/publish/submit_publish_job.py | 7 +++++-- openpype/plugins/publish/collect_rendered_files.py | 11 ++++++++--- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index f44c13767c..f0d914fd7a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -108,7 +108,8 @@ class ExtractAlembic(publish.Extractor): } instance.data["representations"].append(representation) - instance.context.data["cleanupFullPaths"].append(path) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(path) self.log.info("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py index cf6351fdca..5894907795 100644 --- a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py @@ -80,7 +80,8 @@ class ExtractProxyAlembic(publish.Extractor): } instance.data["representations"].append(representation) - instance.context.data["cleanupFullPaths"].append(path) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(path) self.log.info("Extracted {} to {}".format(instance, dirname)) # remove the bounding box diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 4160ac4cb2..3c7277121c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -92,7 +92,8 @@ class ExtractThumbnail(publish.Extractor): "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths - instance.context.data["cleanupFullPaths"].append(dst_staging) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(dst_staging) filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 69e9fb6449..7c29a68dc7 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -844,7 +844,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, do_not_add_review = False if data.get("review"): families.append("review") - elif data.get("review") == False: + elif data.get("review") is False: self.log.debug("Instance has review explicitly disabled.") do_not_add_review = True @@ -872,7 +872,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "useSequenceForReview": data.get("useSequenceForReview", True), # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))), - "colorspace": instance.data.get("colorspace") + "colorspace": instance.data.get("colorspace"), + "stagingDir_persistent": instance.data.get( + "stagingDir_persistent", False + ) } # skip locking version if we are creating v01 diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 6c8d1e9ca5..4b95d8ac44 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -124,6 +124,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.info( f"Adding audio to instance: {instance.data['audio']}") + return instance.data.get("stagingDir_persistent", False) + def process(self, context): self._context = context @@ -160,9 +162,12 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): legacy_io.Session.update(session_data) os.environ.update(session_data) session_is_set = True - self._process_path(data, anatomy) - context.data["cleanupFullPaths"].append(path) - context.data["cleanupEmptyDirs"].append(os.path.dirname(path)) + staging_dir_persistent = self._process_path(data, anatomy) + if not staging_dir_persistent: + context.data["cleanupFullPaths"].append(path) + context.data["cleanupEmptyDirs"].append( + os.path.dirname(path) + ) except Exception as e: self.log.error(e, exc_info=True) raise Exception("Error") from e From 1f6934afbfc18816c53e64d9484405c34ac6c97f Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 26 Jun 2023 16:38:29 +0100 Subject: [PATCH 013/429] staticmethod > classmethod --- .../hosts/maya/plugins/publish/validate_render_image_rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index 96a57ee5d2..fdb069ae43 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -52,7 +52,7 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): cmds.workspace(fileRule=("images", required_images_rule)) cmds.workspace(saveWorkspace=True) - @staticmethod + @classmethod def get_default_render_image_folder(cls, instance): staging_dir = instance.data.get("stagingDir") if staging_dir: From 8492491f8382acc9372017f8ae316117e11cb6ca Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 27 Jun 2023 10:40:51 +0100 Subject: [PATCH 014/429] Fix get_default_render_image_folder arguments. --- .../maya/plugins/publish/validate_render_image_rule.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index fdb069ae43..f8c848e08b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -27,7 +27,7 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): def process(self, instance): required_images_rule = os.path.normpath( - self.get_default_render_image_folder(self, instance) + self.get_default_render_image_folder(instance) ) current_images_rule = os.path.normpath( cmds.workspace(fileRuleEntry="images") @@ -43,9 +43,7 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - required_images_rule = cls.get_default_render_image_folder( - cls, instance - ) + required_images_rule = cls.get_default_render_image_folder(instance) current_images_rule = cmds.workspace(fileRuleEntry="images") if current_images_rule != required_images_rule: From a6727800a36be1d8abb25f4ffe19858f552c0d94 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 3 Jul 2023 12:10:39 +0100 Subject: [PATCH 015/429] Dont change thumbnail extraction --- openpype/hosts/maya/plugins/publish/extract_thumbnail.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 3c7277121c..a4e5d4f8df 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -92,8 +92,6 @@ class ExtractThumbnail(publish.Extractor): "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths - if not instance.data.get("stagingDir_persistent", False): - instance.context.data["cleanupFullPaths"].append(dst_staging) filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) From 5c44f12b37374b48c8f905834ff55e1bec99dee4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 00:51:48 +0200 Subject: [PATCH 016/429] Cleanup code - Refactor `get_file_node_files` because popping from `paths` by index should have been done in reversed order anyway. It's now changed to not need popping at all. - Removed unused `RENDERER_NODE_TYPES` and if-branch which collected `node_attrs` list which was unused + collected members which was also done outside of the if branch and thus generated no extra data. - Collected all materials from look set attributes at once instead of multiple queries - Collected all file nodes in history from a single query instead of per type - Restructured assignment of `instance.data["resources"]` to be more readable - Cached `PXR_NODES` only ones (Note: plugin load is checked on discovery of the collect look plugin) instead of querying plugin load and its nodes per file node per attribute - Removed some debug logs or combined some messages --- .../maya/plugins/publish/collect_look.py | 192 ++++++------------ 1 file changed, 64 insertions(+), 128 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 287ddc228b..6832411190 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -17,11 +17,6 @@ SHAPE_ATTRS = ["castsShadows", "visibleInRefractions", "doubleSided", "opposite"] - -RENDERER_NODE_TYPES = [ - # redshift - "RedshiftMeshParameters" -] SHAPE_ATTRS = set(SHAPE_ATTRS) @@ -36,12 +31,13 @@ def get_pxr_multitexture_file_attrs(node): FILE_NODES = { + # maya "file": "fileTextureName", - + # arnold (mtoa) "aiImage": "filename", - + # redshift "RedshiftNormalMap": "tex0", - + # renderman "PxrBump": "filename", "PxrNormalMap": "filename", "PxrMultiTexture": get_pxr_multitexture_file_attrs, @@ -49,6 +45,15 @@ FILE_NODES = { "PxrTexture": "filename" } +# Cache pixar dependency node types so we can perform a type lookup against it +PXR_NODES = set() +if cmds.pluginInfo("RenderMan_for_Maya", query=True, loaded=True): + PXR_NODES = set( + cmds.pluginInfo("RenderMan_for_Maya", + query=True, + dependNode=True) + ) + def get_attributes(dictionary, attr, node=None): # type: (dict, str, str) -> list @@ -232,20 +237,17 @@ def get_file_node_files(node): """ paths = get_file_node_paths(node) - sequences = [] - replaces = [] + + # For sequences get all files and filter to only existing files + result = [] for index, path in enumerate(paths): if node_uses_image_sequence(node, path): glob_pattern = seq_to_glob(path) - sequences.extend(glob.glob(glob_pattern)) - replaces.append(index) + result.extend(glob.glob(glob_pattern)) + elif os.path.exists(path): + result.append(path) - for index in replaces: - paths.pop(index) - - paths.extend(sequences) - - return [p for p in paths if os.path.exists(p)] + return result class CollectLook(pyblish.api.InstancePlugin): @@ -260,7 +262,7 @@ class CollectLook(pyblish.api.InstancePlugin): membership relations. Collects: - lookAttribtutes (list): Nodes in instance with their altered attributes + lookAttributes (list): Nodes in instance with their altered attributes lookSetRelations (list): Sets and their memberships lookSets (list): List of set names included in the look @@ -285,76 +287,31 @@ class CollectLook(pyblish.api.InstancePlugin): instance: Instance to collect. """ - self.log.info("Looking for look associations " - "for %s" % instance.data['name']) - - # Discover related object sets - self.log.info("Gathering sets ...") - sets = self.collect_sets(instance) + self.log.debug("Looking for look associations " + "for %s" % instance.data['name']) # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) - self.log.info("Gathering set relations ...") - # Ensure iteration happen in a list so we can remove keys from the + # Discover related object sets + self.log.debug("Gathering sets ...") + sets = self.collect_sets(instance) + + # Ensure iteration happen in a list to allow removing keys from the # dict within the loop - - # skipped types of attribute on render specific nodes - disabled_types = ["message", "TdataCompound"] - + self.log.info("Gathering set relations ...") for obj_set in list(sets): self.log.debug("From {}".format(obj_set)) - - # if node is specified as renderer node type, it will be - # serialized with its attributes. - if cmds.nodeType(obj_set) in RENDERER_NODE_TYPES: - self.log.info("- {} is {}".format( - obj_set, cmds.nodeType(obj_set))) - - node_attrs = [] - - # serialize its attributes so they can be recreated on look - # load. - for attr in cmds.listAttr(obj_set): - # skip publishedNodeInfo attributes as they break - # getAttr() and we don't need them anyway - if attr.startswith("publishedNodeInfo"): - continue - - # skip attributes types defined in 'disabled_type' list - if cmds.getAttr("{}.{}".format(obj_set, attr), type=True) in disabled_types: # noqa - continue - - node_attrs.append(( - attr, - cmds.getAttr("{}.{}".format(obj_set, attr)), - cmds.getAttr( - "{}.{}".format(obj_set, attr), type=True) - )) - - for member in cmds.ls( - cmds.sets(obj_set, query=True), long=True): - member_data = self.collect_member_data(member, - instance_lookup) - if not member_data: - continue - - # Add information of the node to the members list - sets[obj_set]["members"].append(member_data) - # Get all nodes of the current objectSet (shadingEngine) for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) - if not member_data: - continue - - # Add information of the node to the members list - sets[obj_set]["members"].append(member_data) + if member_data: + # Add information of the node to the members list + sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. - self.log.info("obj_set {}".format(sets[obj_set])) if not sets[obj_set]["members"]: self.log.info( "Removing redundant set information: {}".format(obj_set)) @@ -382,35 +339,26 @@ class CollectLook(pyblish.api.InstancePlugin): "rman__displacement" ] if look_sets: - materials = [] + self.log.debug("Found look sets:\n{}".format(look_sets)) + # Get all material attrs for all look sets to retrieve their inputs + existing_attrs = [] for look in look_sets: - for at in shader_attrs: - try: - con = cmds.listConnections("{}.{}".format(look, at)) - except ValueError: - # skip attributes that are invalid in current - # context. For example in the case where - # Arnold is not enabled. - continue - if con: - materials.extend(con) + for attr in shader_attrs: + if cmds.attributeQuery(attr, node=look_sets, exists=True): + existing_attrs.append("{}.{}".format(look, attr)) + materials = cmds.listConnections(existing_attrs, + source=True, + destination=False) or [] + self.log.debug("Found materials:\n{}".format(materials)) - self.log.info("Found materials:\n{}".format(materials)) - - self.log.info("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets - # history = cmds.listHistory(look_sets) - history = [] - for material in materials: - history.extend(cmds.listHistory(material, ac=True)) - - # handle VrayPluginNodeMtl node - see #1397 - vray_plugin_nodes = cmds.ls( - history, type="VRayPluginNodeMtl", long=True) - for vray_node in vray_plugin_nodes: - history.extend(cmds.listHistory(vray_node, ac=True)) + # history = cmds.listHistory(look_sets, allConnections=True) + history = cmds.listHistory(materials, allConnections=True) + # Since we retrieved history only of the connected materials + # connected to the look sets above we now add direct history + # for some of the look sets directly # handling render attribute sets render_set_types = [ "VRayDisplacement", @@ -428,20 +376,17 @@ class CollectLook(pyblish.api.InstancePlugin): or [] ) - all_supported_nodes = FILE_NODES.keys() - files = [] - for node_type in all_supported_nodes: - files.extend(cmds.ls(history, type=node_type, long=True)) + files = cmds.ls(history, + type=list(FILE_NODES.keys()), + long=True) - self.log.info("Collected file nodes:\n{}".format(files)) + self.log.info("Collected file nodes:{}".format(files)) # Collect textures if any file nodes are found - instance.data["resources"] = [] - for n in files: - for res in self.collect_resources(n): - instance.data["resources"].append(res) - - self.log.info("Collected resources: {}".format( - instance.data["resources"])) + resources = [] + for node in files: + resources.extend(self.collect_resources(node)) + instance.data["resources"] = resources + self.log.debug("Collected resources: {}".format(resources)) # Log warning when no relevant sets were retrieved for the look. if ( @@ -456,7 +401,7 @@ class CollectLook(pyblish.api.InstancePlugin): instance.extend(shader for shader in look_sets if shader not in instance_lookup) - self.log.info("Collected look for %s" % instance) + self.log.debug("Collected look for %s" % instance) def collect_sets(self, instance): """Collect all objectSets which are of importance for publishing @@ -536,14 +481,14 @@ class CollectLook(pyblish.api.InstancePlugin): # Collect changes to "custom" attributes node_attrs = get_look_attrs(node) - self.log.info( - "Node \"{0}\" attributes: {1}".format(node, node_attrs) - ) - # Only include if there are any properties we care about if not node_attrs: continue + self.log.debug( + "Node \"{0}\" attributes: {1}".format(node, node_attrs) + ) + node_attributes = {} for attr in node_attrs: if not cmds.attributeQuery(attr, node=node, exists=True): @@ -574,14 +519,12 @@ class CollectLook(pyblish.api.InstancePlugin): Returns: dict """ - self.log.debug("processing: {}".format(node)) - all_supported_nodes = FILE_NODES.keys() - if cmds.nodeType(node) not in all_supported_nodes: + if cmds.nodeType(node) not in FILE_NODES: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") - self.log.debug(" - got {}".format(cmds.nodeType(node))) + self.log.debug("processing: {} ({})".format(node, cmds.nodeType(node))) attributes = get_attributes(FILE_NODES, cmds.nodeType(node), node) for attribute in attributes: @@ -613,14 +556,7 @@ class CollectLook(pyblish.api.InstancePlugin): # renderman allows nodes to have filename attribute empty while # you can have another incoming connection from different node. - pxr_nodes = set() - if cmds.pluginInfo("RenderMan_for_Maya", query=True, loaded=True): - pxr_nodes = set( - cmds.pluginInfo("RenderMan_for_Maya", - query=True, - dependNode=True) - ) - if not source and cmds.nodeType(node) in pxr_nodes: + if not source and cmds.nodeType(node) in PXR_NODES: self.log.info("Renderman: source is empty, skipping...") continue # We replace backslashes with forward slashes because V-Ray From fab6c0d9c603e1edfcdd86cc007cd7d0776a7ef5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 01:24:00 +0200 Subject: [PATCH 017/429] Fix typo --- openpype/hosts/maya/plugins/publish/collect_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 6832411190..e08bf88872 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -345,7 +345,7 @@ class CollectLook(pyblish.api.InstancePlugin): existing_attrs = [] for look in look_sets: for attr in shader_attrs: - if cmds.attributeQuery(attr, node=look_sets, exists=True): + if cmds.attributeQuery(attr, node=look, exists=True): existing_attrs.append("{}.{}".format(look, attr)) materials = cmds.listConnections(existing_attrs, source=True, From 40dc747f9ae1ba867e8afc14735c1e0d3a40ea0e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 01:24:33 +0200 Subject: [PATCH 018/429] Fix file nodes filtering --- openpype/hosts/maya/plugins/publish/collect_look.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index e08bf88872..21e3d83d13 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -45,6 +45,13 @@ FILE_NODES = { "PxrTexture": "filename" } +# Keep only node types that actually exist +all_node_types = set(cmds.allNodeTypes()) +for node_type in list(FILE_NODES.keys()): + if node_type not in all_node_types: + FILE_NODES.pop(node_type) +del all_node_types + # Cache pixar dependency node types so we can perform a type lookup against it PXR_NODES = set() if cmds.pluginInfo("RenderMan_for_Maya", query=True, loaded=True): @@ -377,10 +384,13 @@ class CollectLook(pyblish.api.InstancePlugin): ) files = cmds.ls(history, + # It's important only node types are passed that + # exist (e.g. for loaded plugins) because otherwise + # the result will turn back empty type=list(FILE_NODES.keys()), long=True) - self.log.info("Collected file nodes:{}".format(files)) + self.log.info("Collected file nodes: {}".format(files)) # Collect textures if any file nodes are found resources = [] for node in files: From 38ab207e06b63eec371e8ef1cf83c90b73d6630d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 01:30:09 +0200 Subject: [PATCH 019/429] Cosmetics: remove new line in log --- openpype/hosts/maya/plugins/publish/collect_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 21e3d83d13..0104c4e1dd 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -357,7 +357,7 @@ class CollectLook(pyblish.api.InstancePlugin): materials = cmds.listConnections(existing_attrs, source=True, destination=False) or [] - self.log.debug("Found materials:\n{}".format(materials)) + self.log.debug("Found materials: {}".format(materials)) # Get the entire node chain of the look sets # history = cmds.listHistory(look_sets, allConnections=True) From ae3364a833efc39ce82d073a4402d95b2d78735d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 01:30:28 +0200 Subject: [PATCH 020/429] Cosmetics: remove new line in logs --- openpype/hosts/maya/plugins/publish/collect_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 0104c4e1dd..f8c5ccf4ee 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -346,7 +346,7 @@ class CollectLook(pyblish.api.InstancePlugin): "rman__displacement" ] if look_sets: - self.log.debug("Found look sets:\n{}".format(look_sets)) + self.log.debug("Found look sets: {}".format(look_sets)) # Get all material attrs for all look sets to retrieve their inputs existing_attrs = [] From 58217d88c57700e7e645d5b7dbca2bc6e5f92bf3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 11:11:59 +0200 Subject: [PATCH 021/429] Ensure file nodes are processed only once --- openpype/hosts/maya/plugins/publish/collect_look.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index f8c5ccf4ee..80c4b61de5 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -383,6 +383,9 @@ class CollectLook(pyblish.api.InstancePlugin): or [] ) + # Ensure unique entries only + history = list(set(history)) + files = cmds.ls(history, # It's important only node types are passed that # exist (e.g. for loaded plugins) because otherwise @@ -390,10 +393,13 @@ class CollectLook(pyblish.api.InstancePlugin): type=list(FILE_NODES.keys()), long=True) + # Sort for log readability + files.sort() + self.log.info("Collected file nodes: {}".format(files)) # Collect textures if any file nodes are found resources = [] - for node in files: + for node in files: # sort for log readability resources.extend(self.collect_resources(node)) instance.data["resources"] = resources self.log.debug("Collected resources: {}".format(resources)) From 95e763dee4d592d94180cf9047eb9210fae9246c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 11:12:46 +0200 Subject: [PATCH 022/429] Clarify that computed source is only used for logging by moving logic closer together --- .../maya/plugins/publish/collect_look.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 80c4b61de5..321df3e61c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -548,27 +548,28 @@ class CollectLook(pyblish.api.InstancePlugin): node, attribute )) - computed_attribute = "{}.{}".format(node, attribute) - if attribute == "fileTextureName": - computed_attribute = node + ".computedFileTextureNamePattern" - self.log.info(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) try: color_space = cmds.getAttr(color_space_attr) except ValueError: # node doesn't have colorspace attribute color_space = "Raw" + # Compare with the computed file path, e.g. the one with # the pattern in it, to generate some logging information # about this difference - computed_source = cmds.getAttr(computed_attribute) - if source != computed_source: - self.log.debug("Detected computed file pattern difference " - "from original pattern: {0} " - "({1} -> {2})".format(node, - source, - computed_source)) + # Only for file nodes with `fileTextureName` attribute + if attribute == "fileTextureName": + computed_source = cmds.getAttr( + "{}.computedFileTextureNamePattern".format(node) + ) + if source != computed_source: + self.log.debug("Detected computed file pattern difference " + "from original pattern: {0} " + "({1} -> {2})".format(node, + source, + computed_source)) # renderman allows nodes to have filename attribute empty while # you can have another incoming connection from different node. From a54cddf0f378eb80bb4d2bd2c7386415ed69f034 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 6 Jul 2023 11:13:12 +0200 Subject: [PATCH 023/429] Clarify in log that it's processing a resource --- openpype/hosts/maya/plugins/publish/collect_look.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 321df3e61c..bbe25c559e 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -540,7 +540,9 @@ class CollectLook(pyblish.api.InstancePlugin): "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") - self.log.debug("processing: {} ({})".format(node, cmds.nodeType(node))) + self.log.debug( + "Collecting resource: {} ({})".format(node, cmds.nodeType(node)) + ) attributes = get_attributes(FILE_NODES, cmds.nodeType(node), node) for attribute in attributes: From 494e662582011ab5e62fc5e814d48a62ef3747f0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 13 Jul 2023 15:13:53 +0800 Subject: [PATCH 024/429] add optional viewport camera validator for validation of renderable camera --- .../publish/validate_no_max_content.py | 1 - .../publish/validate_viewport_camera.py | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_viewport_camera.py diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py index c6a27dace3..73e12e75c9 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -13,7 +13,6 @@ class ValidateMaxContents(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["camera", "maxScene", - "maxrender", "review"] hosts = ["max"] label = "Max Scene Contents" diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py new file mode 100644 index 0000000000..a52b7e0212 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin) +from openpype.pipeline.publish import RepairAction + +from pymxs import runtime as rt + + +class ValidateViewportCamera(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Viewport Camera + + Check if the renderable camera in scene used as viewport + camera for rendering + """ + + order = pyblish.api.ValidatorOrder + families = ["maxrender"] + hosts = ["max"] + label = "Viewport Camera" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + cameras_in_scene = [c for c in rt.Objects + if rt.classOf(c) in rt.Camera.Classes] + if rt.viewport.getCamera() not in cameras_in_scene: + raise PublishValidationError( + "Cameras in Scene not used as viewport camera" + ) + + @classmethod + def repair(cls, instance): + # Get all cameras in the scene + cameras_in_scene = [c for c in rt.Objects + if rt.classOf(c) in rt.Camera.Classes] + # Set the first camera as viewport camera for rendering + if cameras_in_scene: + rt.viewport.setCamera(cameras_in_scene[0]) \ No newline at end of file From ceaffbbfa92f50d2da009b3be30c5df94d015eed Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 13 Jul 2023 15:26:32 +0800 Subject: [PATCH 025/429] add log for repair action --- .../hosts/max/plugins/publish/validate_viewport_camera.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py index a52b7e0212..d5cf85eb69 100644 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -36,8 +36,10 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): # Get all cameras in the scene - cameras_in_scene = [c for c in rt.Objects + cameras_in_scene = [c.name for c in rt.Objects if rt.classOf(c) in rt.Camera.Classes] # Set the first camera as viewport camera for rendering if cameras_in_scene: - rt.viewport.setCamera(cameras_in_scene[0]) \ No newline at end of file + camera = rt.getNodeByName(cameras_in_scene[0]) + rt.viewport.setCamera(camera) + cls.log.info(f"Camera {camera} set as viewport camera") From f7c368892b6a34a808b21a0f4da51b9f74b3228f Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 13 Jul 2023 15:49:16 +0100 Subject: [PATCH 026/429] Fix Maya Deadline submit plugin --- .../deadline/plugins/publish/submit_maya_deadline.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 159ac43289..8193ca2734 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -289,7 +289,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, def process_submission(self): instance = self._instance - context = instance.context filepath = self.scene_path # publish if `use_publish` else workfile @@ -306,13 +305,11 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self._patch_workfile() # Gather needed data ------------------------------------------------ - workspace = context.data["workspaceDir"] - default_render_file = instance.context.data.get('project_settings')\ - .get('maya')\ - .get('RenderSettings')\ - .get('default_render_image_folder') filename = os.path.basename(filepath) - dirname = os.path.join(workspace, default_render_file) + dirname = os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="images") + ) # Fill in common data to payload ------------------------------------ # TODO: Replace these with collected data from CollectRender From 54e2687afa13b4963b86ecbc4b99786e1a57ced2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 18 Jul 2023 10:21:21 +0300 Subject: [PATCH 027/429] create review validator --- .../publish/validate_review_colorspace.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py new file mode 100644 index 0000000000..18ca39234c --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +import pyblish.api + +from collections import defaultdict +from openpype.pipeline import PublishValidationError + + +class ValidateReviewColorspace(pyblish.api.InstancePlugin): + """Validate Review Colorspace parameters. + + + """ + + order = pyblish.api.ValidatorOrder + 0.1 + families = ["review"] + hosts = ["houdini"] + label = "Validate Review Colorspace" + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + ("Colorspace parameter is not valid."), + title=self.label + ) + + @classmethod + def get_invalid(cls, instance): + import hou # noqa + output_node = instance.data.get("output_node") + rop_node = hou.node(instance.data["instance_node"]) + if output_node is None: + cls.log.error( + "SOP Output node in '%s' does not exist. " + "Ensure a valid SOP output path is set." % rop_node.path() + ) + + return [rop_node.path()] + + pattern = rop_node.parm("prim_to_detail_pattern").eval().strip() + if not pattern: + cls.log.debug( + "Alembic ROP has no 'Primitive to Detail' pattern. " + "Validation is ignored.." + ) + return + + build_from_path = rop_node.parm("build_from_path").eval() + if not build_from_path: + cls.log.debug( + "Alembic ROP has 'Build from Path' disabled. " + "Validation is ignored.." + ) + return + + path_attr = rop_node.parm("path_attrib").eval() + if not path_attr: + cls.log.error( + "The Alembic ROP node has no Path Attribute" + "value set, but 'Build Hierarchy from Attribute'" + "is enabled." + ) + return [rop_node.path()] + + # Let's assume each attribute is explicitly named for now and has no + # wildcards for Primitive to Detail. This simplifies the check. + cls.log.debug("Checking Primitive to Detail pattern: %s" % pattern) + cls.log.debug("Checking with path attribute: %s" % path_attr) + + if not hasattr(output_node, "geometry"): + # In the case someone has explicitly set an Object + # node instead of a SOP node in Geometry context + # then for now we ignore - this allows us to also + # export object transforms. + cls.log.warning("No geometry output node found, skipping check..") + return + + # Check if the primitive attribute exists + frame = instance.data.get("frameStart", 0) + geo = output_node.geometryAtFrame(frame) + + # If there are no primitives on the start frame then it might be + # something that is emitted over time. As such we can't actually + # validate whether the attributes exist, because they won't exist + # yet. In that case, just warn the user and allow it. + if len(geo.iterPrims()) == 0: + cls.log.warning( + "No primitives found on current frame. Validation" + " for Primitive to Detail will be skipped." + ) + return + + attrib = geo.findPrimAttrib(path_attr) + if not attrib: + cls.log.info( + "Geometry Primitives are missing " + "path attribute: `%s`" % path_attr + ) + return [output_node.path()] + + # Ensure at least a single string value is present + if not attrib.strings(): + cls.log.info( + "Primitive path attribute has no " + "string values: %s" % path_attr + ) + return [output_node.path()] + + paths = None + for attr in pattern.split(" "): + if not attr.strip(): + # Ignore empty values + continue + + # Check if the primitive attribute exists + attrib = geo.findPrimAttrib(attr) + if not attrib: + # It is allowed to not have the attribute at all + continue + + # The issue can only happen if at least one string attribute is + # present. So we ignore cases with no values whatsoever. + if not attrib.strings(): + continue + + check = defaultdict(set) + values = geo.primStringAttribValues(attr) + if paths is None: + paths = geo.primStringAttribValues(path_attr) + + for path, value in zip(paths, values): + check[path].add(value) + + for path, values in check.items(): + # Whenever a single path has multiple values for the + # Primitive to Detail attribute then we consider it + # inconsistent and invalidate the ROP node's content. + if len(values) > 1: + cls.log.warning( + "Path has multiple values: %s (path: %s)" + % (list(values), path) + ) + return [output_node.path()] From 94ec68ab21182bbb01258a9e73ccb01b10a5a1e8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 00:13:51 +0300 Subject: [PATCH 028/429] update review validator --- .../publish/validate_review_colorspace.py | 153 ++++++------------ 1 file changed, 49 insertions(+), 104 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 18ca39234c..47c1e886d1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -1,143 +1,88 @@ # -*- coding: utf-8 -*- import pyblish.api - -from collections import defaultdict from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import RepairAction +from openpype.hosts.houdini.api.action import SelectROPAction + + +class SetDefaultViewSpaceAction(RepairAction): + label = "Set default view space" + icon = "mdi.monitor" class ValidateReviewColorspace(pyblish.api.InstancePlugin): """Validate Review Colorspace parameters. - + It checks if 'OCIO Colorspace' parameter was set to valid value. """ order = pyblish.api.ValidatorOrder + 0.1 families = ["review"] hosts = ["houdini"] label = "Validate Review Colorspace" + actions = [SetDefaultViewSpaceAction, SelectROPAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( - ("Colorspace parameter is not valid."), + ("'OCIO Colorspace' parameter is not valid."), title=self.label ) @classmethod def get_invalid(cls, instance): import hou # noqa - output_node = instance.data.get("output_node") + rop_node = hou.node(instance.data["instance_node"]) - if output_node is None: - cls.log.error( - "SOP Output node in '%s' does not exist. " - "Ensure a valid SOP output path is set." % rop_node.path() - ) - - return [rop_node.path()] - - pattern = rop_node.parm("prim_to_detail_pattern").eval().strip() - if not pattern: - cls.log.debug( - "Alembic ROP has no 'Primitive to Detail' pattern. " - "Validation is ignored.." - ) - return - - build_from_path = rop_node.parm("build_from_path").eval() - if not build_from_path: - cls.log.debug( - "Alembic ROP has 'Build from Path' disabled. " - "Validation is ignored.." - ) - return - - path_attr = rop_node.parm("path_attrib").eval() - if not path_attr: - cls.log.error( - "The Alembic ROP node has no Path Attribute" - "value set, but 'Build Hierarchy from Attribute'" - "is enabled." - ) - return [rop_node.path()] - - # Let's assume each attribute is explicitly named for now and has no - # wildcards for Primitive to Detail. This simplifies the check. - cls.log.debug("Checking Primitive to Detail pattern: %s" % pattern) - cls.log.debug("Checking with path attribute: %s" % path_attr) - - if not hasattr(output_node, "geometry"): - # In the case someone has explicitly set an Object - # node instead of a SOP node in Geometry context - # then for now we ignore - this allows us to also - # export object transforms. - cls.log.warning("No geometry output node found, skipping check..") - return - - # Check if the primitive attribute exists - frame = instance.data.get("frameStart", 0) - geo = output_node.geometryAtFrame(frame) - - # If there are no primitives on the start frame then it might be - # something that is emitted over time. As such we can't actually - # validate whether the attributes exist, because they won't exist - # yet. In that case, just warn the user and allow it. - if len(geo.iterPrims()) == 0: + if hou.Color.ocio_defaultDisplay() == "default": cls.log.warning( - "No primitives found on current frame. Validation" - " for Primitive to Detail will be skipped." + "Default Houdini colorspace is used, " + " skipping check.." ) return - attrib = geo.findPrimAttrib(path_attr) - if not attrib: - cls.log.info( - "Geometry Primitives are missing " - "path attribute: `%s`" % path_attr + if rop_node.evalParm("colorcorrect") != 2: + # any colorspace settings other than default requires + # 'Color Correct' parm to be set to 'OpenColorIO' + rop_node.setParms({"colorcorrect": 2}) + cls.log.debug( + "'Color Correct' parm on '%s' has been set to" + " 'OpenColorIO'", rop_node ) - return [output_node.path()] - # Ensure at least a single string value is present - if not attrib.strings(): - cls.log.info( - "Primitive path attribute has no " - "string values: %s" % path_attr + if rop_node.evalParm("ociocolorspace") not in \ + hou.Color.ocio_spaces(): + + cls.log.error( + "'OCIO Colorspace' value on '%s' is not valid, " + "select a valid option from the dropdown menu.", + rop_node ) - return [output_node.path()] + return rop_node - paths = None - for attr in pattern.split(" "): - if not attr.strip(): - # Ignore empty values - continue + @classmethod + def repair(cls, instance): + """Set Default View Space Action. - # Check if the primitive attribute exists - attrib = geo.findPrimAttrib(attr) - if not attrib: - # It is allowed to not have the attribute at all - continue + It is a helper action more than a repair action, + used to set colorspace on opengl node to the default view. + """ - # The issue can only happen if at least one string attribute is - # present. So we ignore cases with no values whatsoever. - if not attrib.strings(): - continue + import hou + import PyOpenColorIO as OCIO - check = defaultdict(set) - values = geo.primStringAttribValues(attr) - if paths is None: - paths = geo.primStringAttribValues(path_attr) + rop_node = hou.node(instance.data["instance_node"]) - for path, value in zip(paths, values): - check[path].add(value) + config = OCIO.GetCurrentConfig() + display = hou.Color.ocio_defaultDisplay() + view = hou.Color.ocio_defaultView() - for path, values in check.items(): - # Whenever a single path has multiple values for the - # Primitive to Detail attribute then we consider it - # inconsistent and invalidate the ROP node's content. - if len(values) > 1: - cls.log.warning( - "Path has multiple values: %s (path: %s)" - % (list(values), path) - ) - return [output_node.path()] + default_view_space = config.getDisplayColorSpaceName( + display, view) + + rop_node.setParms({"ociocolorspace" : default_view_space}) + cls.log.debug( + "'OCIO Colorspace' parm on '%s' has been set to '%s'", + default_view_space, rop_node + ) From 4d79320cf4f1abdd05dc3ce6fc46d6ccb30b2aca Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 00:50:43 +0300 Subject: [PATCH 029/429] make hound happy --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 47c1e886d1..02284dc641 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -81,7 +81,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): default_view_space = config.getDisplayColorSpaceName( display, view) - rop_node.setParms({"ociocolorspace" : default_view_space}) + rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( "'OCIO Colorspace' parm on '%s' has been set to '%s'", default_view_space, rop_node From f4a5858edbed465f11908d18697b5dc0c6414b76 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 19:43:39 +0300 Subject: [PATCH 030/429] update validator --- .../houdini/plugins/publish/validate_review_colorspace.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 02284dc641..8f3799cde4 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -33,9 +33,10 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): import hou # noqa + import os rop_node = hou.node(instance.data["instance_node"]) - if hou.Color.ocio_defaultDisplay() == "default": + if os.getenv("OCIO") is None: cls.log.warning( "Default Houdini colorspace is used, " " skipping check.." @@ -78,8 +79,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): display = hou.Color.ocio_defaultDisplay() view = hou.Color.ocio_defaultView() - default_view_space = config.getDisplayColorSpaceName( - display, view) + default_view_space = config.getDisplayViewColorSpaceName( + display, view) # works with PyOpenColorIO 2.2.1 rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( From 00d4afd1178d4ee95ee8d09a4a8c8348f3948606 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 19:44:45 +0300 Subject: [PATCH 031/429] make hound happy --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 8f3799cde4..addfa05bf1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -80,7 +80,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): view = hou.Color.ocio_defaultView() default_view_space = config.getDisplayViewColorSpaceName( - display, view) # works with PyOpenColorIO 2.2.1 + display, view) # works with PyOpenColorIO 2.2.1 rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( From d90d45c56df70e876d02acbf9d4e09aa1b6f5746 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 20 Jul 2023 13:38:09 +0300 Subject: [PATCH 032/429] move get function to colorspace.py --- .../publish/validate_review_colorspace.py | 19 +++++--- openpype/pipeline/colorspace.py | 36 ++++++++++++++++ openpype/scripts/ocio_wrapper.py | 43 +++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index addfa05bf1..cfc5a5d71d 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -71,19 +71,24 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): """ import hou - import PyOpenColorIO as OCIO + from openpype.pipeline.colorspace import get_display_view_colorspace_name + from openpype.hosts.houdini.api.lib import get_color_management_preferences #noqa rop_node = hou.node(instance.data["instance_node"]) - config = OCIO.GetCurrentConfig() - display = hou.Color.ocio_defaultDisplay() - view = hou.Color.ocio_defaultView() + data = get_color_management_preferences() + config_path = data.get("config") + display = data.get("display") + view = data.get("view") - default_view_space = config.getDisplayViewColorSpaceName( - display, view) # works with PyOpenColorIO 2.2.1 + cls.log.debug("Get default view colorspace name..") + + default_view_space = get_display_view_colorspace_name(config_path, + display, view) rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( - "'OCIO Colorspace' parm on '%s' has been set to '%s'", + "'OCIO Colorspace' parm on '%s' has been set to " + "the default view color space '%s'", default_view_space, rop_node ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3f2d4891c1..a1d86b2fec 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -589,3 +589,39 @@ def _get_imageio_settings(project_settings, host_name): imageio_host = project_settings.get(host_name, {}).get("imageio", {}) return imageio_global, imageio_host + +def get_display_view_colorspace_name(config_path, display, view): + + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + return get_display_view_colorspace_subprocess(config_path, + display, view) + + from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name #noqa + + return _get_display_view_colorspace_name(config_path, display, view) + +def get_display_view_colorspace_subprocess(config_path, display, view): + with _make_temp_json_file() as tmp_json_path: + # Prepare subprocess arguments + args = [ + "run", get_ocio_config_script_path(), + "config", "get_display_view_colorspace_name", + "--in_path", config_path, + "--out_path", tmp_json_path, + "--display", display, + "--view", view + + ] + log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": log + } + + run_openpype_process(*args, **process_kwargs) + + # return all colorspaces + return_json_data = open(tmp_json_path).read() + return json.loads(return_json_data) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 16558642c6..ca703fd65c 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -173,6 +173,49 @@ def _get_views_data(config_path): return data +def _get_display_view_colorspace_name(config_path, display, view): + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError("Input path should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + colorspace = config.getDisplayViewColorSpaceName(display, view) + + return colorspace + +@config.command( + name="get_display_view_colorspace_name", + help=( + "return default view colorspace name " + "for the given display and view " + "--path input arg is required" + ) +) +@click.option("--in_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +@click.option("--display", required=True, + help="display", + type=click.STRING) +@click.option("--view", required=True, + help="view", + type=click.STRING) +def get_display_view_colorspace_name(in_path, out_path, + display, view): + + json_path = Path(out_path) + + out_data = _get_display_view_colorspace_name(in_path, + display, view) + + with open(json_path, "w") as f: + json.dump(out_data, f) + + print(f"Viewer data are saved to '{json_path}'") if __name__ == '__main__': main() From 2bb11d956ca2ec629f61d5d14e0b2ef4130a0363 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 20 Jul 2023 13:54:46 +0300 Subject: [PATCH 033/429] add doc strings --- .../publish/validate_review_colorspace.py | 2 +- openpype/pipeline/colorspace.py | 22 ++++++++++++ openpype/scripts/ocio_wrapper.py | 34 +++++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index cfc5a5d71d..67e29e0ee2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -71,7 +71,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): """ import hou - from openpype.pipeline.colorspace import get_display_view_colorspace_name + from openpype.pipeline.colorspace import get_display_view_colorspace_name #noqa from openpype.hosts.houdini.api.lib import get_color_management_preferences #noqa rop_node = hou.node(instance.data["instance_node"]) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a1d86b2fec..22e8175a7e 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -591,6 +591,16 @@ def _get_imageio_settings(project_settings, host_name): return imageio_global, imageio_host def get_display_view_colorspace_name(config_path, display, view): + """get view colorspace name for the given display and view. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ if not compatibility_check(): # python environment is not compatible with PyOpenColorIO @@ -603,6 +613,18 @@ def get_display_view_colorspace_name(config_path, display, view): return _get_display_view_colorspace_name(config_path, display, view) def get_display_view_colorspace_subprocess(config_path, display, view): + """get view colorspace name for the given display and view + via subprocess. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + with _make_temp_json_file() as tmp_json_path: # Prepare subprocess arguments args = [ diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index ca703fd65c..f94faabe11 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -174,6 +174,21 @@ def _get_views_data(config_path): return data def _get_display_view_colorspace_name(config_path, display, view): + """get view colorspace name for the given display and view. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + + Raises: + IOError: Input config does not exist. + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + config_path = Path(config_path) if not config_path.is_file(): @@ -199,13 +214,28 @@ def _get_display_view_colorspace_name(config_path, display, view): help="path where to write output json file", type=click.Path()) @click.option("--display", required=True, - help="display", + help="display name", type=click.STRING) @click.option("--view", required=True, - help="view", + help="view name", type=click.STRING) def get_display_view_colorspace_name(in_path, out_path, display, view): + """Aggregate view colorspace name to file. + + Python 2 wrapped console command + + Args: + in_path (str): config file path string + out_path (str): temp json file path string + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Example of use: + > pyton.exe ./ocio_wrapper.py config \ + get_display_view_colorspace_name --in_path= \ + --out_path= --display= --view= + """ json_path = Path(out_path) From c264a6abf1a2c6605b9d7d51d2e2cdb6c0d9a63b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 24 Jul 2023 08:14:57 +0100 Subject: [PATCH 034/429] Disable hardlinking. --- .../hosts/maya/plugins/publish/extract_look.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index e2c88ef44a..50a6db3bf7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -6,7 +6,6 @@ import contextlib import json import logging import os -import platform import tempfile import six import attr @@ -585,14 +584,12 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary fix to NOT create hardlinks on windows machines - if platform.system().lower() == "windows": - self.log.info( - "Forcing copy instead of hardlink due to issues on Windows..." - ) - force_copy = True - else: - force_copy = instance.data.get("forceCopy", False) + # Temporary disable all hardlinking, due to the feature not being used + # or properly working. + self.log.info( + "Forcing copy instead of hardlink." + ) + force_copy = True destinations_cache = {} From a2e18323ff1103861891cf84dd9e2ac3b6efab53 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 27 Jul 2023 22:32:43 +0300 Subject: [PATCH 035/429] update fbx creator --- .../houdini/plugins/create/create_fbx.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/create/create_fbx.py diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py new file mode 100644 index 0000000000..65f613bdea --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating fbx.""" +from openpype.hosts.houdini.api import plugin + +import hou + + +class CreateFilmboxFBX(plugin.HoudiniCreator): + """Filmbox FBX Driver""" + identifier = "io.openpype.creators.houdini.filmboxfbx" + label = "Filmbox FBX" + family = "filmboxfbx" + icon = "fa5s.cubes" + + def create(self, subset_name, instance_data, pre_create_data): + instance_data.pop("active", None) + instance_data.update({"node_type": "filmboxfbx"}) + + instance = super(CreateFilmboxFBX, self).create( + subset_name, + instance_data, + pre_create_data) + + instance_node = hou.node(instance.get("instance_node")) + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name)) + + parms = { + "sopoutput": output_path + } + + if self.selected_nodes: + selected_node = self.selected_nodes[0] + + # Although Houdini allows ObjNode path on `startnode` for the + # the ROP node we prefer it set to the SopNode path explicitly + + # Allow sop level paths (e.g. /obj/geo1/box1) + if isinstance(selected_node, hou.SopNode): + parms["startnode"] = selected_node.path() + self.log.debug( + "Valid SopNode selection, 'Export' in filmboxfbx" + " will be set to '%s'." + % selected_node + ) + + # Allow object level paths to Geometry nodes (e.g. /obj/geo1) + # but do not allow other object level nodes types like cameras, etc. + elif isinstance(selected_node, hou.ObjNode) and \ + selected_node.type().name() in ["geo"]: + + # get the output node with the minimum + # 'outputidx' or the node with display flag + sop_path = self.get_obj_output(selected_node) + + if sop_path: + parms["startnode"] = sop_path.path() + self.log.debug( + "Valid ObjNode selection, 'Export' in filmboxfbx " + "will be set to the child path '%s'." + % sop_path + ) + + if not parms.get("startnode", None): + self.log.debug( + "Selection isn't valid. 'Export' in filmboxfbx will be empty." + ) + else: + self.log.debug( + "No Selection. 'Export' in filmboxfbx will be empty." + ) + + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = [] + self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] + + def get_obj_output(self, obj_node): + """Find output node with the smallest 'outputidx'.""" + + outputs = obj_node.subnetOutputs() + + # if obj_node is empty + if not outputs: + return + + # if obj_node has one output child whether its + # sop output node or a node with the render flag + elif len(outputs) == 1: + return outputs[0] + + # if there are more than one, then it have multiple ouput nodes + # return the one with the minimum 'outputidx' + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) From 5a9281823d3d18cc9857875b796f7e65d9c747f1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 00:06:55 +0300 Subject: [PATCH 036/429] update create_fbx --- .../houdini/plugins/create/create_fbx.py | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 65f613bdea..b59a8ccfc6 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -12,23 +12,61 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): family = "filmboxfbx" icon = "fa5s.cubes" + # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): - instance_data.pop("active", None) + # instance_data.pop("active", None) + + # set node type instance_data.update({"node_type": "filmboxfbx"}) + # set essential extra parameters instance = super(CreateFilmboxFBX, self).create( subset_name, instance_data, pre_create_data) + # get the created node instance_node = hou.node(instance.get("instance_node")) + + # get output path output_path = hou.text.expandString( "$HIP/pyblish/{}.fbx".format(subset_name)) + # get selection + selection = self.get_selection() + + # parms dictionary parms = { + "startnode" : selection, "sopoutput": output_path } + # set parms + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = [] + self.lock_parameters(instance_node, to_lock) + + # Overrides HoudiniCreator.get_network_categories() + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] + + def get_selection(self): + """Selection Logic. + + how self.selected_nodes should be processed to get + the desirable node from selection. + + Returns: + str : node path + """ + + selection = "" + if self.selected_nodes: selected_node = self.selected_nodes[0] @@ -37,7 +75,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # Allow sop level paths (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): - parms["startnode"] = selected_node.path() + selection = selected_node.path() self.log.debug( "Valid SopNode selection, 'Export' in filmboxfbx" " will be set to '%s'." @@ -54,14 +92,14 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): sop_path = self.get_obj_output(selected_node) if sop_path: - parms["startnode"] = sop_path.path() + selection = sop_path.path() self.log.debug( "Valid ObjNode selection, 'Export' in filmboxfbx " "will be set to the child path '%s'." % sop_path ) - if not parms.get("startnode", None): + if not selection: self.log.debug( "Selection isn't valid. 'Export' in filmboxfbx will be empty." ) @@ -70,20 +108,12 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): "No Selection. 'Export' in filmboxfbx will be empty." ) - instance_node.setParms(parms) - - # Lock any parameters in this list - to_lock = [] - self.lock_parameters(instance_node, to_lock) - - def get_network_categories(self): - return [ - hou.ropNodeTypeCategory(), - hou.sopNodeTypeCategory() - ] + return selection def get_obj_output(self, obj_node): - """Find output node with the smallest 'outputidx'.""" + """Find output node with the smallest 'outputidx' + or return tje node with the render flag instead. + """ outputs = obj_node.subnetOutputs() From 9724ea4c84f51e68785cbd54c1d3eef87cdd4a3f Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 00:08:23 +0300 Subject: [PATCH 037/429] update create_fbx --- openpype/hosts/houdini/plugins/create/create_fbx.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index b59a8ccfc6..d4594edcbe 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- -"""Creator plugin for creating fbx.""" +"""Creator plugin for creating fbx. + +It was made to pratice publish process. +""" from openpype.hosts.houdini.api import plugin import hou From 8a431f2d44c967daab56dce3ed06fdfd7bcae0c5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 00:18:47 +0300 Subject: [PATCH 038/429] update create_fbx --- openpype/hosts/houdini/plugins/create/create_fbx.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index d4594edcbe..30cc98ddb9 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -31,11 +31,12 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # get the created node instance_node = hou.node(instance.get("instance_node")) - # get output path + # Get this node specific parms + # 1. get output path output_path = hou.text.expandString( "$HIP/pyblish/{}.fbx".format(subset_name)) - # get selection + # 2. get selection selection = self.get_selection() # parms dictionary From 1db5d5f838b78a4e807385ab5a202cd13134f1d5 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 28 Jul 2023 08:27:37 +0100 Subject: [PATCH 039/429] Ensure legacy_io_distinct is not used in AYON --- openpype/hosts/maya/plugins/publish/extract_look.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 50a6db3bf7..ec01a817b3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -18,6 +18,7 @@ from openpype.lib.vendor_bin_utils import find_executable from openpype.lib import source_hash, run_subprocess, get_oiio_tools_path from openpype.pipeline import legacy_io, publish, KnownPublishError from openpype.hosts.maya.api import lib +from openpype import AYON_SERVER_ENABLED # Modes for transfer COPY = 1 @@ -50,6 +51,12 @@ def find_paths_by_hash(texture_hash): str: path to texture if found. """ + if AYON_SERVER_ENABLED: + raise ValueError( + "This is a bug. \"find_paths_by_hash\" is not compatible with " + "AYON." + ) + key = "data.sourceHashes.{0}".format(texture_hash) return legacy_io.distinct(key, {"type": "version"}) From e4c9275ac36e1900d48a3605f2693a189ab92158 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 20:08:20 +0300 Subject: [PATCH 040/429] update default_variants --- openpype/hosts/houdini/plugins/create/create_fbx.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 30cc98ddb9..18fd879ac7 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -15,6 +15,9 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): family = "filmboxfbx" icon = "fa5s.cubes" + default_variant = "FBX" + default_variants = ["FBX", "Main", "Test"] + # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): # instance_data.pop("active", None) From 919247b3d4675b5febbb3ebebdc2b92075312399 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 21:17:38 +0300 Subject: [PATCH 041/429] update creator --- .../houdini/plugins/create/create_fbx.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 18fd879ac7..fed1ad0562 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -4,6 +4,7 @@ It was made to pratice publish process. """ from openpype.hosts.houdini.api import plugin +from openpype.lib import EnumDef import hou @@ -42,10 +43,14 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # 2. get selection selection = self.get_selection() + # 3. get Vertex Cache Format + vcformat = pre_create_data.get("vcformat") + # parms dictionary parms = { "startnode" : selection, - "sopoutput": output_path + "sopoutput": output_path, + "vcformat" : vcformat } # set parms @@ -62,6 +67,19 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): hou.sopNodeTypeCategory() ] + # Overrides HoudiniCreator.get_pre_create_attr_defs() + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + vcformat = EnumDef("vcformat", + items={ + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") + + return attrs + [vcformat] + def get_selection(self): """Selection Logic. From 7918668e72ec743c63d966cbf1db3f60159292ef Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 22:15:59 +0300 Subject: [PATCH 042/429] add Valid Frame Range --- .../hosts/houdini/plugins/create/create_fbx.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index fed1ad0562..0345e07ea6 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -46,11 +46,15 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # 3. get Vertex Cache Format vcformat = pre_create_data.get("vcformat") + # 3. get Valid Frame Range + trange = pre_create_data.get("trange") + # parms dictionary parms = { "startnode" : selection, "sopoutput": output_path, - "vcformat" : vcformat + "vcformat" : vcformat, + "trange" : trange } # set parms @@ -77,8 +81,15 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): }, default=0, label="Vertex Cache Format") + trange = EnumDef("trange", + items={ + 0: "Render Current Frame", + 1: "Render Frame Range" + }, + default=0, + label="Valid Frame Range") - return attrs + [vcformat] + return attrs + [vcformat, trange] def get_selection(self): """Selection Logic. From ed2a48e2cd9af28fdd45237cecd9d1d9c17eab7e Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Sat, 29 Jul 2023 02:28:02 +0300 Subject: [PATCH 043/429] add fbx collector --- .../plugins/publish/collect_fbx_type.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/collect_fbx_type.py diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py new file mode 100644 index 0000000000..f25dbf3a5b --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -0,0 +1,56 @@ +"""Collector for filmboxfbx types. + +A Collector can act as a preprocessor for the validation stage. +It is used mainly to update instance.data + +P.S. + There are some collectors that run by default for all types. +""" +import pyblish.api + + +class CollectFilmboxfbxType(pyblish.api.InstancePlugin): + """Collect data type for filmboxfbx instance.""" + + order = pyblish.api.CollectorOrder + hosts = ["houdini"] + families = ["filmboxfbx"] + label = "Collect type of filmboxfbx" + + def process(self, instance): + + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 + # such a condition can be used to differentiate between + # instances by identifier even if they have the same type. + pass + + # Update instance.data with ouptut_node + out_node = self.get_output_node(instance) + + if out_node: + instance.data["output_node"] = out_node + + # Disclaimer : As a convntin we use collect_output_node.py + # to Update instance.data with ouptut_node of different types + # however, we use this collector instead for demonstration + + + def get_output_node(self, instance): + """Getting output_node Logic. + + It's moved here so that it become easier to focus on + process method. + """ + + import hou + + # get output node + node = hou.node(instance.data["instance_node"]) + out_node = node.parm("startnode").evalAsNode() + + if not out_node: + self.log.warning("No output node collected.") + return + + self.log.debug("Output node: %s" % out_node.path()) + return out_node From 051f03f4fa4e4718c6be56878ae2f8b07b5a3a11 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 31 Jul 2023 21:00:16 +0300 Subject: [PATCH 044/429] add export validator --- .../publish/validate_fbx_export_node.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py new file mode 100644 index 0000000000..f9f2461415 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""Validator plugin for Export node in filmbox instance.""" +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateNoExportPath(pyblish.api.InstancePlugin): + """Validate if Export node in filmboxfbx instance exists.""" + + order = pyblish.api.ValidatorOrder + families = ["filmboxfbx"] + label = "Validate Filmbox Export Node" + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Export node is incorrect", + title="Invalid Export Node" + ) + + @classmethod + def get_invalid(cls, instance): + + import hou + + fbx_rop = hou.node(instance.data.get("instance_node")) + export_node = fbx_rop.parm("startnode").evalAsNode() + + if not export_node: + cls.log.error( + ("Empty Export ('Export' parameter) found in " + "the filmbox instance - {}".format(fbx_rop.path())) + ) + return [fbx_rop] + + if not isinstance(export_node, hou.SopNode): + cls.log.error( + "Export node '{}' is not pointing to valid SOP" + " node".format(export_node.path()) + ) + return [export_node] From 1a6e7aa041cfdaffbae58ddfada7e03c753b89b5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 1 Aug 2023 22:05:16 +0800 Subject: [PATCH 045/429] adding OpenpypeData parameter as custom attributes back to the loaded objects --- openpype/hosts/max/api/pipeline.py | 25 ++++++++++++++++++ .../hosts/max/plugins/load/load_camera_fbx.py | 26 ++++++++----------- .../hosts/max/plugins/load/load_max_scene.py | 16 +++++++----- openpype/hosts/max/plugins/load/load_model.py | 18 +++++++++---- .../hosts/max/plugins/load/load_model_fbx.py | 9 ++++--- .../hosts/max/plugins/load/load_model_obj.py | 12 +++++---- .../hosts/max/plugins/load/load_model_usd.py | 11 +++++--- .../hosts/max/plugins/load/load_pointcache.py | 15 ++++++++--- .../hosts/max/plugins/load/load_pointcloud.py | 15 ++++++----- .../max/plugins/load/load_redshift_proxy.py | 5 ++-- 10 files changed, 101 insertions(+), 51 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 03b85a4066..82470dd510 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -15,8 +15,10 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.menu import OpenPypeMenu from openpype.hosts.max.api import lib +from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB from openpype.hosts.max import MAX_HOST_DIR + from pymxs import runtime as rt # noqa log = logging.getLogger("openpype.hosts.max") @@ -170,3 +172,26 @@ def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container + + +def load_OpenpypeData(container, loaded_nodes): + """Function to load the OpenpypeData Parameter along with + the published objects + + Args: + container (str): target container to set up + the custom attributes + loaded_nodes (list): list of nodes to be loaded + """ + attrs = rt.Execute(MS_CUSTOM_ATTRIB) + if rt.custAttributes.get(container.baseObject, attrs): + rt.custAttributes.delete(container.baseObject, attrs) + rt.custAttributes.add(container.baseObject, attrs) + node_list = [] + for i in loaded_nodes: + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + + # Setting the property + rt.setProperty( + container.openPypeData, "all_handles", node_list) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 62284b23d9..6b16bfe474 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -32,8 +32,9 @@ class FbxLoader(load.LoaderPlugin): if not container: container = rt.Container() container.name = f"{name}" - - for selection in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + load_OpenpypeData(container, selections) + for selection in selections: selection.Parent = container return containerise( @@ -45,18 +46,13 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) rt.Select(node.Children) - fbx_reimport_cmd = ( - f""" - -FBXImporterSetParam "Animation" true -FBXImporterSetParam "Cameras" true -FBXImporterSetParam "AxisConversionMethod" true -FbxExporterSetParam "UpAxis" "Y" -FbxExporterSetParam "Preserveinstances" true - -importFile @"{path}" #noPrompt using:FBXIMP - """) - rt.Execute(fbx_reimport_cmd) + rt.FBXImporterSetParam("Animation", True) + rt.FBXImporterSetParam("Camera", True) + rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Preserveinstances", True) + rt.ImportFile( + path, rt.name("noPrompt"), using=rt.FBXIMP) + load_OpenpypeData(node, node.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..468461bc0e 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,8 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData + from openpype.pipeline import get_representation_path, load @@ -27,6 +28,7 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") + load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -39,16 +41,16 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) + rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() container_node = rt.GetNodeByName(node_name) + instance_name, _ = os.path.splitext(node_name) + instance_container = rt.GetNodeByName(instance_name) for max_object in max_objects: - max_object.Parent = container_node - + max_object.Parent = instance_container + instance_container.Parent = container_node + load_OpenpypeData(container_node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index cff82a593c..810fc65968 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,6 +1,6 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -45,7 +45,10 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - + selections = rt.GetCurrentSelection() + abc_selections = [abc for abc in selections + if abc.name != "Alembic"] + load_OpenpypeData(abc_container, abc_selections) return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -57,6 +60,10 @@ class ModelAbcLoader(load.LoaderPlugin): node = rt.GetNodeByName(container["instance_node"]) rt.Select(node.Children) + nodes_list = [] + with maintained_selection(): + rt.Select(node) + for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) @@ -67,9 +74,10 @@ class ModelAbcLoader(load.LoaderPlugin): for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path - - with maintained_selection(): - rt.Select(node) + nodes_list.append(alembic_obj) + abc_selections = [abc for abc in nodes_list + if abc.name != "Alembic"] + load_OpenpypeData(node, abc_selections) lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 12f526ab95..8f2b4f4ac3 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,6 +1,6 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -28,7 +28,10 @@ class FbxModelLoader(load.LoaderPlugin): container = rt.Container() container.name = name - for selection in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + load_OpenpypeData(container, selections) + + for selection in selections: selection.Parent = container return containerise( @@ -47,7 +50,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - + load_OpenpypeData(container, node.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 18a19414fa..83b5ec49b9 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,7 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -25,9 +25,10 @@ class ObjLoader(load.LoaderPlugin): # create "missing" container for obj import container = rt.Container() container.name = name - + selections = rt.GetCurrentSelection() + load_OpenpypeData(container, selections) # get current selection - for selection in rt.GetCurrentSelection(): + for selection in selections: selection.Parent = container asset = rt.GetNodeByName(name) @@ -49,9 +50,10 @@ class ObjLoader(load.LoaderPlugin): rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection - for selection in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + for selection in selections: selection.Parent = container - + load_OpenpypeData(container, container.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 48b50b9b18..a1961e6d89 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -2,7 +2,7 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -30,8 +30,10 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - + selections = rt.GetCurrentSelection() asset = rt.GetNodeByName(name) + mesh_selections = [r for r in selections if r != asset] + load_OpenpypeData(asset, mesh_selections) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -55,11 +57,12 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogPath = log_filepath rt.LogLevel = rt.Name("info") - rt.USDImporter.importFile(path, - importOptions=import_options) + rt.USDImporter.importFile( + path, importOptions=import_options) asset = rt.GetNodeByName(instance_name) asset.Parent = node + load_OpenpypeData(asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 290503e053..026938feff 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,7 +7,7 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData class AbcLoader(load.LoaderPlugin): @@ -48,11 +48,15 @@ class AbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - - for abc in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + abc_selections = [abc for abc in selections + if abc.name != "Alembic"] + load_OpenpypeData(abc_container, abc_selections) + for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 + return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -71,7 +75,7 @@ class AbcLoader(load.LoaderPlugin): container["instance_node"], {"representation": str(representation["_id"])}, ) - + nodes_list = [] with maintained_selection(): rt.Select(node.Children) @@ -85,6 +89,9 @@ class AbcLoader(load.LoaderPlugin): for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path + nodes_list.append(alembic_obj) + abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] + load_OpenpypeData(node, abc_selections) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2a1175167a..18998f4529 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -21,8 +21,11 @@ class PointCloudLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.GetNodeByName(obj.name) + prt_container = rt.container() + prt_container.name = name + obj.Parent = prt_container + load_OpenpypeData(prt_container, [obj]) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -38,10 +41,10 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + load_OpenpypeData(node, node.Children) + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 31692f6367..b62400d2e5 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,7 +5,7 @@ from openpype.pipeline import ( load, get_representation_path ) -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.hosts.max.api import lib @@ -33,7 +33,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - + load_OpenpypeData(container, [rs_proxy]) asset = rt.getNodeByName(name) return containerise( @@ -49,6 +49,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path + load_OpenpypeData(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From ecc583f8f2eec059e8589e6c45934540085aad34 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 22:04:04 +0300 Subject: [PATCH 046/429] update create collect validate --- .../houdini/plugins/create/create_fbx.py | 11 +- .../plugins/publish/collect_fbx_type.py | 1 + .../publish/validate_fbx_export_node.py | 43 ---- .../publish/validate_fbx_hierarchy_path.py | 220 ++++++++++++++++++ .../publish/validate_sop_output_node.py | 2 +- 5 files changed, 231 insertions(+), 46 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 0345e07ea6..ee2fdcd73f 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -2,7 +2,14 @@ """Creator plugin for creating fbx. It was made to pratice publish process. + +Filmbox by default expects an ObjNode +it's by default selects the output sop with mimimum idx +or the node with render flag isntead. + +to eleminate any confusion, we set the sop node explictly. """ + from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef @@ -16,8 +23,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): family = "filmboxfbx" icon = "fa5s.cubes" - default_variant = "FBX" - default_variants = ["FBX", "Main", "Test"] + default_variant = "Main" + default_variants = ["Main", "Test"] # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index f25dbf3a5b..05a0af659f 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -17,6 +17,7 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): families = ["filmboxfbx"] label = "Collect type of filmboxfbx" + # overrides InstancePlugin.process() def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py deleted file mode 100644 index f9f2461415..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator plugin for Export node in filmbox instance.""" -import pyblish.api -from openpype.pipeline import PublishValidationError - - -class ValidateNoExportPath(pyblish.api.InstancePlugin): - """Validate if Export node in filmboxfbx instance exists.""" - - order = pyblish.api.ValidatorOrder - families = ["filmboxfbx"] - label = "Validate Filmbox Export Node" - - def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError( - "Export node is incorrect", - title="Invalid Export Node" - ) - - @classmethod - def get_invalid(cls, instance): - - import hou - - fbx_rop = hou.node(instance.data.get("instance_node")) - export_node = fbx_rop.parm("startnode").evalAsNode() - - if not export_node: - cls.log.error( - ("Empty Export ('Export' parameter) found in " - "the filmbox instance - {}".format(fbx_rop.path())) - ) - return [fbx_rop] - - if not isinstance(export_node, hou.SopNode): - cls.log.error( - "Export node '{}' is not pointing to valid SOP" - " node".format(export_node.path()) - ) - return [export_node] diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py new file mode 100644 index 0000000000..24f88b384f --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +"""It's almost the same as +'validate_primitive_hierarchy_paths.py' +however this one includes more comments for demonstration. + +FYI, path for fbx behaves a little differently. +In maya terms: +in Filmbox FBX: it sets the name of the object +in Alembic ROP: it sets the name of the shape +""" + +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou + +# Each validation can have a single repair action +# which calls the repair method +class AddDefaultPathAction(RepairAction): + label = "Add a default path" + icon = "mdi.pencil-plus-outline" + + +class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): + """Validate all primitives build hierarchy from attribute + when enabled. + + The name of the attribute must exist on the prims and have the + same name as Build Hierarchy from Attribute's `Path Attribute` + value on the FilmBox node. + This validation enables 'Build Hierarchy from Attribute' + by default. + """ + + # Usually you will this value by default + order = ValidateContentsOrder + 0.1 + families = ["filmboxfbx"] + hosts = ["houdini"] + label = "Validate FBX Hierarchy Path" + + # Validation can have as many actions as you want + # all of these actions are defined in a seperate place + # unlike the repair action + actions = [SelectInvalidAction, AddDefaultPathAction, + SelectROPAction] + + # overrides InstancePlugin.process() + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.name() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes), + title=self.label + ) + + # This method was named get_invalid as a convention + # it's also used by SelectInvalidAction to select + # the returned node + @classmethod + def get_invalid(cls, instance): + + output_node = instance.data.get("output_node") + rop_node = hou.node(instance.data["instance_node"]) + + if output_node is None: + cls.log.error( + "SOP Output node in '%s' does not exist. " + "Ensure a valid SOP output path is set.", + rop_node.path() + ) + + return [rop_node] + + build_from_path = rop_node.parm("buildfrompath").eval() + if not build_from_path: + cls.log.debug( + "Filmbox FBX has 'Build from Path' disabled. " + "Enbaling it as default." + ) + rop_node.parm("buildfrompath").set(1) + + path_attr = rop_node.parm("pathattrib").eval() + if not path_attr: + cls.log.debug( + "Filmbox FBX node has no Path Attribute" + "value set, setting it to 'path' as default." + ) + rop_node.parm("pathattrib").set("path") + + cls.log.debug("Checking for attribute: %s", path_attr) + + if not hasattr(output_node, "geometry"): + # In the case someone has explicitly set an Object + # node instead of a SOP node in Geometry context + # then for now we ignore - this allows us to also + # export object transforms. + cls.log.warning("No geometry output node found," + " skipping check..") + return + + # Check if the primitive attribute exists + frame = instance.data.get("frameStart", 0) + geo = output_node.geometryAtFrame(frame) + + # If there are no primitives on the current frame then + # we can't check whether the path names are correct. + # So we'll just issue a warning that the check can't + # be done consistently and skip validation. + + if len(geo.iterPrims()) == 0: + cls.log.warning( + "No primitives found on current frame." + " Validation for primitive hierarchy" + " paths will be skipped," + " thus can't be validated." + ) + return + + # Check if there are any values for the primitives + attrib = geo.findPrimAttrib(path_attr) + if not attrib: + cls.log.info( + "Geometry Primitives are missing " + "path attribute: `%s`", path_attr + ) + return [output_node] + + # Ensure at least a single string value is present + if not attrib.strings(): + cls.log.info( + "Primitive path attribute has no " + "string values: %s", path_attr + ) + return [output_node] + + paths = geo.primStringAttribValues(path_attr) + # Ensure all primitives are set to a valid path + # Collect all invalid primitive numbers + invalid_prims = [i for i, path in enumerate(paths) if not path] + if invalid_prims: + num_prims = len(geo.iterPrims()) # faster than len(geo.prims()) + cls.log.info( + "Prims have no value for attribute `%s` " + "(%s of %s prims)", + path_attr, len(invalid_prims), num_prims + ) + return [output_node] + + # what repair action expects to find and call + @classmethod + def repair(cls, instance): + """Add a default path attribute Action. + + It is a helper action more than a repair action, + used to add a default single value for the path. + """ + + rop_node = hou.node(instance.data["instance_node"]) + # I'm doing so because an artist may change output node + # before clicking the button. + output_node = rop_node.parm("startnode").evalAsNode() + + if not output_node: + cls.log.debug( + "Action isn't performed, invalid SOP Path on %s", + rop_node + ) + return + + # This check to prevent the action from running multiple times. + # git_invalid only returns [output_node] when + # path attribute is the problem + if cls.get_invalid(instance) != [output_node]: + return + + path_attr = rop_node.parm("pathattrib").eval() + + path_node = output_node.parent().createNode("name", + "AUTO_PATH") + path_node.parm("attribname").set(path_attr) + path_node.parm("name1").set('`opname("..")`_GEO') + + cls.log.debug( + "'%s' was created. It adds '%s' with a default" + " single value", path_node, path_attr + ) + + path_node.setGenericFlag(hou.nodeFlag.DisplayComment, True) + path_node.setComment( + 'Auto path node was created automatically by ' + '"Add a default path attribute"' + '\nFeel free to modify or replace it.' + ) + + if output_node.type().name() in ["null", "output"]: + # Connect before + path_node.setFirstInput(output_node.input(0)) + path_node.moveToGoodPosition() + output_node.setFirstInput(path_node) + output_node.moveToGoodPosition() + else: + # Connect after + path_node.setFirstInput(output_node) + rop_node.parm("startnode").set(path_node.path()) + path_node.moveToGoodPosition() + + cls.log.debug( + "SOP path on '%s' updated to new output node '%s'", + rop_node, path_node + ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index d9dee38680..da9752505a 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -22,7 +22,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache"] + families = ["pointcache", "vdbcache", "filmboxfbx"] hosts = ["houdini"] label = "Validate Output Node" actions = [SelectROPAction, SelectInvalidAction] From 0a7d6aa31845a172dea4899f98a99bac66d806d8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 22:09:01 +0300 Subject: [PATCH 047/429] update validate --- .../houdini/plugins/publish/validate_fbx_hierarchy_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 24f88b384f..7e890db58e 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -56,7 +56,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - nodes = [n.name() for n in invalid] + nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes), From 39225ee0479c0bdf257d02fcfff236d105806fd6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 22:54:05 +0300 Subject: [PATCH 048/429] add fbx extractor --- .../houdini/plugins/publish/extract_fbx.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/extract_fbx.py diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py new file mode 100644 index 0000000000..70bf3e1017 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -0,0 +1,50 @@ +import os + +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.houdini.api.lib import render_rop + +import hou + + +class ExtractRedshiftProxy(publish.Extractor): + + order = pyblish.api.ExtractorOrder + 0.1 + label = "Extract FilmBox FBX" + families = ["filmboxfbx"] + hosts = ["houdini"] + + # overrides InstancePlugin.process() + def process(self, instance): + + ropnode = hou.node(instance.data.get("instance_node")) + + # Get the filename from the filename parameter + # `.evalParm(parameter)` will make sure all tokens are resolved + output = ropnode.evalParm("sopoutput") + staging_dir = os.path.normpath(os.path.dirname(output)) + instance.data["stagingDir"] = staging_dir + file_name = os.path.basename(output) + + self.log.info("Writing FBX '%s' to '%s'" % (file_name, + staging_dir)) + + render_rop(ropnode) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + "name": "fbx", + "ext": "fbx", + "files": file_name, + "stagingDir": staging_dir, + } + + # A single frame may also be rendered without start/end frame. + if "frameStart" in instance.data and "frameEnd" in instance.data: + representation["frameStart"] = instance.data["frameStart"] + representation["frameEnd"] = instance.data["frameEnd"] + + instance.data["representations"].append(representation) From b507c2d52b2b2ad23ec1d300e8f5d8cbbd97fb58 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 23:03:55 +0300 Subject: [PATCH 049/429] register filmbox family in integrate.py --- openpype/plugins/publish/integrate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index ffb9acf4a7..05ffe0bd3d 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -138,7 +138,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene" + "blendScene", + "filmboxfbx" ] default_template_name = "publish" From 686ba073cef895b17bc6393e4a4dd9bbb4bf73f5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 23:06:40 +0300 Subject: [PATCH 050/429] update comments --- openpype/hosts/houdini/plugins/publish/collect_fbx_type.py | 1 + openpype/hosts/houdini/plugins/publish/extract_fbx.py | 3 ++- .../houdini/plugins/publish/validate_fbx_hierarchy_path.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 05a0af659f..c665852bb6 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -12,6 +12,7 @@ import pyblish.api class CollectFilmboxfbxType(pyblish.api.InstancePlugin): """Collect data type for filmboxfbx instance.""" + # Usually you will use this value as default order = pyblish.api.CollectorOrder hosts = ["houdini"] families = ["filmboxfbx"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 70bf3e1017..6a4b541c33 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -10,12 +10,13 @@ import hou class ExtractRedshiftProxy(publish.Extractor): + # Usually you will use this value as default order = pyblish.api.ExtractorOrder + 0.1 label = "Extract FilmBox FBX" families = ["filmboxfbx"] hosts = ["houdini"] - # overrides InstancePlugin.process() + # overrides Extractor.process() def process(self, instance): ropnode = hou.node(instance.data.get("instance_node")) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 7e890db58e..871b347155 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -40,7 +40,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): by default. """ - # Usually you will this value by default + # Usually you will use this value as default order = ValidateContentsOrder + 0.1 families = ["filmboxfbx"] hosts = ["houdini"] From 9d1b8c6af9e721ae3703f8b11ce2cb2c3fe3c4f4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 19:34:36 +0800 Subject: [PATCH 051/429] use alembic object to store the OP parameters --- openpype/hosts/max/plugins/load/load_model.py | 4 +++- openpype/hosts/max/plugins/load/load_pointcache.py | 8 +++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index 810fc65968..efd758063d 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -61,6 +61,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node.Children) nodes_list = [] + abc_object = None with maintained_selection(): rt.Select(node) @@ -69,6 +70,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) + abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: @@ -77,7 +79,7 @@ class ModelAbcLoader(load.LoaderPlugin): nodes_list.append(alembic_obj) abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] - load_OpenpypeData(node, abc_selections) + load_OpenpypeData(abc_object, abc_selections) lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 026938feff..7af588566e 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -67,15 +67,12 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - alembic_objects = self.get_container_children(node, "AlembicObject") - for alembic_object in alembic_objects: - alembic_object.source = path - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, ) nodes_list = [] + abc_object = None with maintained_selection(): rt.Select(node.Children) @@ -84,6 +81,7 @@ class AbcLoader(load.LoaderPlugin): rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) + abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: @@ -91,7 +89,7 @@ class AbcLoader(load.LoaderPlugin): alembic_obj.source = path nodes_list.append(alembic_obj) abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] - load_OpenpypeData(node, abc_selections) + load_OpenpypeData(abc_object, abc_selections) def switch(self, container, representation): self.update(container, representation) From 7a74608b11bfd891d52b92ae2bdf78ebf3dcd468 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 21:42:26 +0300 Subject: [PATCH 052/429] add loader --- .../hosts/houdini/plugins/load/load_fbx.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/load/load_fbx.py diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py new file mode 100644 index 0000000000..e69bdbb10a --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +"""Fbx Loader for houdini. + +It's an exact copy of +'load_bgeo.py' +however this one includes extra comments for demonstration. + +This plugin is part of publish process guide. +""" +import os +import re + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class FbxLoader(load.LoaderPlugin): + """Load fbx files to Houdini.""" + + label = "Load FBX" + families = ["filmboxfbx", "fbx"] + representations = ["fbx"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import hou + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # Create a new geo node + container = obj.createNode("geo", node_name=node_name) + is_sequence = bool(context["representation"]["context"].get("frame")) + + # Remove the file node, it only loads static meshes + # Houdini 17 has removed the file node from the geo node + file_node = container.node("file1") + if file_node: + file_node.destroy() + + # Explicitly create a file node + path = self.filepath_from_context(context) + file_node = container.createNode("file", node_name=node_name) + file_node.setParms( + {"file": self.format_path(path, context["representation"])}) + + # Set display on last node + file_node.setDisplayFlag(True) + + nodes = [container, file_node] + self[:] = nodes + + return pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + @staticmethod + def format_path(path, representation): + """Format file path correctly for single bgeo or bgeo sequence.""" + if not os.path.exists(path): + raise RuntimeError("Path does not exist: %s" % path) + + is_sequence = bool(representation["context"].get("frame")) + # The path is either a single file or sequence in a folder. + if not is_sequence: + filename = path + else: + filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) + + filename = os.path.join(path, filename) + + filename = os.path.normpath(filename) + filename = filename.replace("\\", "/") + + return filename + + def update(self, container, representation): + + node = container["node"] + try: + file_node = next( + n for n in node.children() if n.type().name() == "file" + ) + except StopIteration: + self.log.error("Could not find node of type `file`") + return + + # Update the file path + file_path = get_representation_path(representation) + file_path = self.format_path(file_path, representation) + + file_node.setParms({"file": file_path}) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() + + def switch(self, container, representation): + self.update(container, representation) From a1b0c409aca7af1f9d44563bc4c797d884b67b5a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 21:43:50 +0300 Subject: [PATCH 053/429] update doc strings --- openpype/hosts/houdini/plugins/create/create_fbx.py | 2 ++ .../hosts/houdini/plugins/publish/collect_fbx_type.py | 2 ++ .../plugins/publish/validate_fbx_hierarchy_path.py | 8 ++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index ee2fdcd73f..ac76dc0441 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -8,6 +8,8 @@ it's by default selects the output sop with mimimum idx or the node with render flag isntead. to eleminate any confusion, we set the sop node explictly. + +This plugin is part of publish process guide. """ from openpype.hosts.houdini.api import plugin diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index c665852bb6..f1665b27f0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -5,6 +5,8 @@ It is used mainly to update instance.data P.S. There are some collectors that run by default for all types. + +This plugin is part of publish process guide. """ import pyblish.api diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 871b347155..6a2bda1bca 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -1,12 +1,16 @@ # -*- coding: utf-8 -*- -"""It's almost the same as +"""Validate path attribute for all primitives. + +It's almost the same as 'validate_primitive_hierarchy_paths.py' -however this one includes more comments for demonstration. +however this one includes extra comments for demonstration. FYI, path for fbx behaves a little differently. In maya terms: in Filmbox FBX: it sets the name of the object in Alembic ROP: it sets the name of the shape + +This plugin is part of publish process guide. """ import pyblish.api From 08fdbf1283151e2109f1649321700ea8697c261d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 23:34:32 +0300 Subject: [PATCH 054/429] make loader look prettier --- .../hosts/houdini/plugins/load/load_fbx.py | 151 +++++++++++------- 1 file changed, 96 insertions(+), 55 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index e69bdbb10a..5afd39ea99 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -23,71 +23,25 @@ class FbxLoader(load.LoaderPlugin): label = "Load FBX" families = ["filmboxfbx", "fbx"] representations = ["fbx"] - order = -10 - icon = "code-fork" - color = "orange" + order = -10 # you can use this by default. + icon = "code-fork" # you can use this by default. + color = "orange" # you can use this by default. def load(self, context, name=None, namespace=None, data=None): - import hou + file_path = self.get_file_path(context) - # Get the root node - obj = hou.node("/obj") + namespace, node_name = self.get_node_name(context, name, namespace) - # Define node name - namespace = namespace if namespace else context["asset"]["name"] - node_name = "{}_{}".format(namespace, name) if namespace else name + nodes = self.create_load_node_tree(file_path, node_name, name) - # Create a new geo node - container = obj.createNode("geo", node_name=node_name) - is_sequence = bool(context["representation"]["context"].get("frame")) - - # Remove the file node, it only loads static meshes - # Houdini 17 has removed the file node from the geo node - file_node = container.node("file1") - if file_node: - file_node.destroy() - - # Explicitly create a file node - path = self.filepath_from_context(context) - file_node = container.createNode("file", node_name=node_name) - file_node.setParms( - {"file": self.format_path(path, context["representation"])}) - - # Set display on last node - file_node.setDisplayFlag(True) - - nodes = [container, file_node] self[:] = nodes - return pipeline.containerise( - node_name, - namespace, - nodes, - context, - self.__class__.__name__, - suffix="", + containerised_nodes = self.get_containerised_nodes( + nodes, context, node_name, namespace ) - @staticmethod - def format_path(path, representation): - """Format file path correctly for single bgeo or bgeo sequence.""" - if not os.path.exists(path): - raise RuntimeError("Path does not exist: %s" % path) - - is_sequence = bool(representation["context"].get("frame")) - # The path is either a single file or sequence in a folder. - if not is_sequence: - filename = path - else: - filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) - - filename = os.path.join(path, filename) - - filename = os.path.normpath(filename) - filename = filename.replace("\\", "/") - - return filename + return containerised_nodes def update(self, container, representation): @@ -116,3 +70,90 @@ class FbxLoader(load.LoaderPlugin): def switch(self, container, representation): self.update(container, representation) + + def get_file_path(self, context): + """Return formatted file path.""" + + # Format file name, Houdini only wants forward slashes + file_path = self.filepath_from_context(context) + file_path = os.path.normpath(file_path) + file_path = file_path.replace("\\", "/") + + return file_path + + def get_node_name(self, context, name=None, namespace=None): + """Define node name.""" + + if not namespace: + namespace = context["asset"]["name"] + + if namespace: + node_name = "{}_{}".format(namespace, name) + else: + node_name = name + + return namespace, node_name + + def create_load_node_tree(self, file_path, node_name, subset_name): + """Create Load node network. + + you can start building your tree at any obj level. + it'll be much easier to build it in the root obj level. + + Afterwards, your tree will be automatically moved to + '/obj/AVALON_CONTAINERS' subnetwork. + """ + import hou + + # Get the root obj level + obj = hou.node("/obj") + + # Create a new obj geo node + parent_node = obj.createNode("geo", node_name=node_name) + + # In older houdini, + # when reating a new obj geo node, a default file node will be + # automatically created. + # so, we will delete it if exists. + file_node = parent_node.node("file1") + if file_node: + file_node.destroy() + + # Create a new file node + file_node = parent_node.createNode("file", node_name= node_name) + file_node.setParms({"file":file_path}) + + # Create attribute delete + attribdelete_name = "attribdelete_{}".format(subset_name) + attribdelete = parent_node.createNode("attribdelete", + node_name= attribdelete_name) + attribdelete.setParms({"ptdel":"fbx_*"}) + attribdelete.setInput(0, file_node) + + # Create a Null node + null_name = "OUT_{}".format(subset_name) + null = parent_node.createNode("null", node_name= null_name) + null.setInput(0, attribdelete) + + # Ensure display flag is on the file_node input node and not on the OUT + # node to optimize "debug" displaying in the viewport. + file_node.setDisplayFlag(True) + + # Set new position for unpack node else it gets cluttered + nodes = [parent_node, file_node, attribdelete, null] + for nr, node in enumerate(nodes): + node.setPosition([0, (0 - nr)]) + + return nodes + + def get_containerised_nodes(self, nodes, context, node_name, namespace): + containerised_nodes = pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + return containerised_nodes From de56b53ac6c48f0ca73b9111ce0edbf73d02d950 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 23:35:28 +0300 Subject: [PATCH 055/429] update doc string --- openpype/hosts/houdini/plugins/load/load_fbx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 5afd39ea99..6bac7a7cec 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """Fbx Loader for houdini. -It's an exact copy of -'load_bgeo.py' +It's almost a copy of +'load_bgeo.py'and 'load_alembic.py' however this one includes extra comments for demonstration. This plugin is part of publish process guide. From ba5f7eb417ccf86edccb4a3b1ef2b6dfe64fecca Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 23:36:33 +0300 Subject: [PATCH 056/429] remove un-necessary import --- openpype/hosts/houdini/plugins/load/load_fbx.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 6bac7a7cec..3f66c8e88f 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -8,7 +8,6 @@ however this one includes extra comments for demonstration. This plugin is part of publish process guide. """ import os -import re from openpype.pipeline import ( load, From 5b68f0cef6fc72b0a7e011e31acfc9bd6b7b2c9d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 7 Aug 2023 16:32:34 +0800 Subject: [PATCH 057/429] remove loading openpype attributes in load max scene as it is being loaded differently --- openpype/hosts/max/plugins/load/load_max_scene.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 468461bc0e..f161a19a4c 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import containerise from openpype.pipeline import get_representation_path, load @@ -28,7 +28,6 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") - load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -50,7 +49,6 @@ class MaxSceneLoader(load.LoaderPlugin): for max_object in max_objects: max_object.Parent = instance_container instance_container.Parent = container_node - load_OpenpypeData(container_node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 69f88b33d2ce3a96463105e7e3ff7e4066509bbc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 7 Aug 2023 18:17:10 +0800 Subject: [PATCH 058/429] rstore max scene code --- openpype/hosts/max/plugins/load/load_max_scene.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index f161a19a4c..468461bc0e 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -28,6 +28,7 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") + load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -49,6 +50,7 @@ class MaxSceneLoader(load.LoaderPlugin): for max_object in max_objects: max_object.Parent = instance_container instance_container.Parent = container_node + load_OpenpypeData(container_node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 31f8c4cd13ef4240566f8dbf98af86f2488d3936 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 7 Aug 2023 18:59:04 +0300 Subject: [PATCH 059/429] update fbx creator --- .../houdini/plugins/create/create_fbx.py | 62 +++++++++++-------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index ac76dc0441..9e1dbea2f4 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -4,10 +4,12 @@ It was made to pratice publish process. Filmbox by default expects an ObjNode -it's by default selects the output sop with mimimum idx -or the node with render flag isntead. +however, we set the sop node explictly +to eleminate any confusion. -to eleminate any confusion, we set the sop node explictly. +This creator by default will select +the output sop with mimimum idx +or the node with render flag isntead. This plugin is part of publish process guide. """ @@ -30,12 +32,11 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): - # instance_data.pop("active", None) # set node type instance_data.update({"node_type": "filmboxfbx"}) - # set essential extra parameters + # create instance (calls super create method) instance = super(CreateFilmboxFBX, self).create( subset_name, instance_data, @@ -44,33 +45,14 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # get the created node instance_node = hou.node(instance.get("instance_node")) - # Get this node specific parms - # 1. get output path - output_path = hou.text.expandString( - "$HIP/pyblish/{}.fbx".format(subset_name)) - - # 2. get selection - selection = self.get_selection() - - # 3. get Vertex Cache Format - vcformat = pre_create_data.get("vcformat") - - # 3. get Valid Frame Range - trange = pre_create_data.get("trange") - - # parms dictionary - parms = { - "startnode" : selection, - "sopoutput": output_path, - "vcformat" : vcformat, - "trange" : trange - } + # get parms + parms = self.get_parms(subset_name, pre_create_data) # set parms instance_node.setParms(parms) # Lock any parameters in this list - to_lock = [] + to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) # Overrides HoudiniCreator.get_network_categories() @@ -100,6 +82,32 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): return attrs + [vcformat, trange] + def get_parms(self, subset_name, pre_create_data): + """Get parameters values for this specific node.""" + + # 1. get output path + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name)) + + # 2. get selection + selection = self.get_selection() + + # 3. get Vertex Cache Format + vcformat = pre_create_data.get("vcformat") + + # 4. get Valid Frame Range + trange = pre_create_data.get("trange") + + # parms dictionary + parms = { + "startnode" : selection, + "sopoutput": output_path, + "vcformat" : vcformat, + "trange" : trange + } + + return parms + def get_selection(self): """Selection Logic. From e35c9898af6716703f5d468f3e7490cdc7ddecc9 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:08:31 +0200 Subject: [PATCH 060/429] Align creator settings to use default_variants instead of defaults --- .../plugins/create/create_arnold_ass.py | 2 +- .../plugins/create/create_arnold_rop.py | 2 +- .../plugins/create/create_karma_rop.py | 2 +- .../plugins/create/create_mantra_rop.py | 2 +- .../plugins/create/create_redshift_rop.py | 2 +- .../houdini/plugins/create/create_vray_rop.py | 2 +- .../defaults/project_settings/houdini.json | 56 ++++- .../defaults/project_settings/maya.json | 47 ++-- .../schemas/schema_houdini_create.json | 206 ++++++++++-------- .../schemas/schema_maya_create.json | 26 ++- .../schemas/schema_maya_create_render.json | 4 +- .../schemas/template_create_plugin.json | 4 +- 12 files changed, 211 insertions(+), 144 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py index 8b310753d0..e587041e70 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -10,7 +10,7 @@ class CreateArnoldAss(plugin.HoudiniCreator): label = "Arnold ASS" family = "ass" icon = "magic" - defaults = ["Main"] + default_variants = ["Main"] # Default extension: `.ass` or `.ass.gz` ext = ".ass" diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py index ca516619f6..7d00e8b3e5 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py @@ -9,7 +9,7 @@ class CreateArnoldRop(plugin.HoudiniCreator): label = "Arnold ROP" family = "arnold_rop" icon = "magic" - defaults = ["master"] + default_variants = ["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 71c2bf1b28..e8b77c12e5 100644 --- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -11,7 +11,7 @@ class CreateKarmaROP(plugin.HoudiniCreator): label = "Karma ROP" family = "karma_rop" icon = "magic" - defaults = ["master"] + default_variants = ["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..b2846d53fa 100644 --- a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py @@ -11,7 +11,7 @@ class CreateMantraROP(plugin.HoudiniCreator): label = "Mantra ROP" family = "mantra_rop" icon = "magic" - defaults = ["master"] + default_variants = ["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..9b0a36ddd1 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py @@ -13,7 +13,7 @@ class CreateRedshiftROP(plugin.HoudiniCreator): label = "Redshift ROP" family = "redshift_rop" icon = "magic" - defaults = ["master"] + default_variants = ["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..a3ae01f5ae 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,7 +14,7 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - defaults = ["master"] + default_variants = ["master"] ext = "exr" diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index a53f1ff202..389fdc6d96 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,48 +14,80 @@ "create": { "CreateArnoldAss": { "enabled": true, - "defaults": [], + "default_variants": [], "ext": ".ass" }, + "CreateArnoldRop": { + "enabled": true, + "default_variants": [] + }, "CreateAlembicCamera": { "enabled": true, - "defaults": [] + "default_variants": [] + }, + "CreateBGEO": { + "enabled": true, + "default_variants": [] }, "CreateCompositeSequence": { "enabled": true, - "defaults": [] + "default_variants": [] + }, + "CreateHDA": { + "enabled": true, + "default_variants": [] + }, + "CreateKarmaROP": { + "enabled": true, + "default_variants": [] + }, + "CreateMantraROP": { + "enabled": true, + "default_variants": [] }, "CreatePointCache": { "enabled": true, - "defaults": [] + "default_variants": [] + }, + "CreateRedshiftProxy": { + "enabled": true, + "default_variants": [] }, "CreateRedshiftROP": { "enabled": true, - "defaults": [] + "default_variants": [] }, "CreateRemotePublish": { "enabled": true, - "defaults": [] + "default_variants": [] }, - "CreateVDBCache": { + "CreateReview": { "enabled": true, - "defaults": [] + "default_variants": [] }, "CreateUSD": { "enabled": false, - "defaults": [] + "default_variants": [] }, "CreateUSDModel": { "enabled": false, - "defaults": [] + "default_variants": [] }, "USDCreateShadingWorkspace": { "enabled": false, - "defaults": [] + "default_variants": [] }, "CreateUSDRender": { "enabled": false, - "defaults": [] + "default_variants": [] + }, + "CreateVDBCache": { + "enabled": true, + "default_variants": [] + }, + "CreateVrayROP": { + "enabled": true, + "default_variants": [] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 8e1022f877..b9817fe400 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,19 +521,19 @@ "enabled": true, "make_tx": true, "rs_tex": false, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateRender": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateUnrealStaticMesh": { "enabled": true, - "defaults": [ + "default_variants": [ "", "_Main" ], @@ -547,7 +547,7 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, - "defaults": [], + "default_variants": [], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { @@ -555,11 +555,12 @@ "publish_mip_map": true }, "CreateAnimation": { + "enabled": false, "write_color_sets": false, "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -567,7 +568,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "defaults": [ + "default_variants": [ "Main", "Proxy", "Sculpt" @@ -578,7 +579,7 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -586,20 +587,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,61 +622,61 @@ "enabled": true, "vrmesh": true, "alembic": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsd": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateAssembly": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateCamera": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateLayout": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMayaScene": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateRig": { "enabled": true, - "defaults": [ + "default_variants": [ "Main", "Sim", "Cloth" @@ -683,20 +684,20 @@ }, "CreateSetDress": { "enabled": true, - "defaults": [ + "default_variants": [ "Main", "Anim" ] }, "CreateVRayScene": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": 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..52a03677da 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 @@ -1,89 +1,121 @@ { - "type": "dict", - "collapsible": true, - "key": "create", - "label": "Creator plugins", - "children": [ - { - "type": "dict", - "collapsible": true, - "key": "CreateArnoldAss", - "label": "Create Arnold Ass", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "defaults", - "label": "Default Subsets", - "object_type": "text" - }, - { - "type": "enum", - "key": "ext", - "label": "Default Output Format (extension)", - "multiselection": false, - "enum_items": [ - { - ".ass": ".ass" - }, - { - ".ass.gz": ".ass.gz (gzipped)" - } - ] - } - ] - - }, + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ { - "type": "schema_template", - "name": "template_create_plugin", - "template_data": [ - { - "key": "CreateAlembicCamera", - "label": "Create Alembic Camera" - }, - { - "key": "CreateCompositeSequence", - "label": "Create Composite (Image Sequence)" - }, - { - "key": "CreatePointCache", - "label": "Create Point Cache" - }, - { - "key": "CreateRedshiftROP", - "label": "Create Redshift ROP" - }, - { - "key": "CreateRemotePublish", - "label": "Create Remote Publish" - }, - { - "key": "CreateVDBCache", - "label": "Create VDB Cache" - }, - { - "key": "CreateUSD", - "label": "Create USD" - }, - { - "key": "CreateUSDModel", - "label": "Create USD Model" - }, - { - "key": "USDCreateShadingWorkspace", - "label": "Create USD Shading Workspace" - }, - { - "key": "CreateUSDRender", - "label": "Create USD Render" - } - ] - } - ] -} \ No newline at end of file + "type": "dict", + "collapsible": true, + "key": "CreateArnoldAss", + "label": "Create Arnold Ass", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Variants", + "object_type": "text" + }, + { + "type": "enum", + "key": "ext", + "label": "Default Output Format (extension)", + "multiselection": false, + "enum_items": [ + { + ".ass": ".ass" + }, + { + ".ass.gz": ".ass.gz (gzipped)" + } + ] + } + ] + + }, + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateArnoldRop", + "label": "Create Arnold ROP" + }, + { + "key": "CreateAlembicCamera", + "label": "Create Alembic Camera" + }, + { + "key": "CreateBGEO", + "label": "Create Houdini BGEO" + }, + { + "key": "CreateCompositeSequence", + "label": "Create Composite (Image Sequence)" + }, + { + "key": "CreateKarmaROP", + "label": "Create Karma ROP" + }, + { + "key": "CreateMantraROP", + "label": "Create Mantra ROP" + }, + { + "key": "CreateHDA", + "label": "Create HDA" + }, + { + "key": "CreatePointCache", + "label": "Create Point Cache" + }, + { + "key": "CreateRedshiftProxy", + "label": "Create Redshift Proxy" + }, + { + "key": "CreateRedshiftROP", + "label": "Create Redshift ROP" + }, + { + "key": "CreateRemotePublish", + "label": "Create Remote Publish" + }, + { + "key": "CreateReview", + "label": "Create Review" + }, + { + "key": "CreateUSD", + "label": "Create USD" + }, + { + "key": "CreateUSDModel", + "label": "Create USD Model" + }, + { + "key": "USDCreateShadingWorkspace", + "label": "Create USD Shading Workspace" + }, + { + "key": "CreateUSDRender", + "label": "Create USD Render" + }, + { + "key": "CreateVDBCache", + "label": "Create VDB Cache" + }, + { + "key": "CreateVrayROP", + "label": "Create VRay ROP" + } + ] + } + ] +} 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..8faf3fcae8 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" }, @@ -120,10 +120,12 @@ "collapsible": true, "key": "CreateAnimation", "label": "Create Animation", + "checkbox_key": "enabled", "children": [ { - "type": "label", - "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." + "type": "boolean", + "key": "enabled", + "label": "Enabled" }, { "type": "boolean", @@ -147,7 +149,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -177,7 +179,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -212,7 +214,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -242,7 +244,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -262,7 +264,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -287,7 +289,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -389,7 +391,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json index 68ad7ad63d..9d7432fe51 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json @@ -12,9 +12,9 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 14d15e7840..3d2ed9f3d4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -13,8 +13,8 @@ }, { "type": "list", - "key": "defaults", - "label": "Default Subsets", + "key": "default_variants", + "label": "Default Variants", "object_type": "text" } ] From 1b794361034c023acef1c3362df6065f1839245a Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:08:50 +0200 Subject: [PATCH 061/429] Add function so Houdini creator settings get applied to instances --- openpype/hosts/houdini/api/plugin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 1e7eaa7e22..a5efb73c67 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -292,3 +292,22 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """ return [hou.ropNodeTypeCategory()] + + def get_creator_settings(self, project_settings, settings_key=None): + if not settings_key: + settings_key = self.__class__.__name__ + return project_settings["houdini"]["create"][settings_key] + + def apply_settings( + self, + project_settings, + system_settings + ): + """Method called on initialization of plugin to apply settings.""" + + # plugin settings + plugin_settings = self.get_creator_settings(project_settings) + + # individual attributes + self.default_variants = plugin_settings.get( + "default_variants") or self.default_variants From 73bbc86d8b54dbe7859737f34d6db71120d6f693 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:09:27 +0200 Subject: [PATCH 062/429] Use default variant from creator plugin for interactive shelve tool --- .../hosts/houdini/api/creator_node_shelves.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 7c6122cffe..c724acb16d 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -35,11 +35,11 @@ CATEGORY_GENERIC_TOOL = { CREATE_SCRIPT = """ from openpype.hosts.houdini.api.creator_node_shelves import create_interactive -create_interactive("{identifier}", **kwargs) +create_interactive("{identifier}", "{variant}", **kwargs) """ -def create_interactive(creator_identifier, **kwargs): +def create_interactive(creator_identifier, default_variant, **kwargs): """Create a Creator using its identifier interactively. This is used by the generated shelf tools as callback when a user selects @@ -59,9 +59,9 @@ def create_interactive(creator_identifier, **kwargs): """ # TODO Use Qt instead - result, variant = hou.ui.readInput('Define variant name', + result, variant = hou.ui.readInput("Define variant name", buttons=("Ok", "Cancel"), - initial_contents='Main', + initial_contents=default_variant, title="Define variant", help="Set the variant for the " "publish instance", @@ -196,7 +196,14 @@ def install(): key = "openpype_create.{}".format(identifier) log.debug(f"Registering {key}") - script = CREATE_SCRIPT.format(identifier=identifier) + default_variant = "Main" + if hasattr(creator, "default_variant"): + default_variant = creator.default_variant + elif hasattr(creator, "default_variants"): + default_variant = creator.default_variants[0] + script = CREATE_SCRIPT.format( + identifier=identifier, variant=default_variant + ) data = { "script": script, "language": hou.scriptLanguage.Python, From 3f626080d6fd042e5f5a5505d8ff08ae298b8b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:28:19 +0200 Subject: [PATCH 063/429] Remove default_variants class variable --- openpype/hosts/houdini/plugins/create/create_arnold_ass.py | 2 -- 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 | 1 - 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 72b12ddba2..12d08f7d83 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -10,8 +10,6 @@ class CreateArnoldAss(plugin.HoudiniCreator): label = "Arnold ASS" family = "ass" icon = "magic" - default_variants = ["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 7d00e8b3e5..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" - default_variants = ["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 cb9c6dd711..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" - default_variants = ["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 b2846d53fa..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" - default_variants = ["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 9bb1d58d8f..e37718129c 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" - default_variants = ["master"] ext = "exr" diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py index a3ae01f5ae..34c8906bb0 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,7 +14,6 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - default_variants = ["master"] ext = "exr" From adca11d44bed5c705d4c3e7d9740f732053b7779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:28:58 +0200 Subject: [PATCH 064/429] Remove extra whitespace --- openpype/hosts/houdini/plugins/create/create_redshift_rop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py index e37718129c..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" - ext = "exr" def create(self, subset_name, instance_data, pre_create_data): From 84e3ade492bf51084824d520fb914a334a2361d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:30:16 +0200 Subject: [PATCH 065/429] Revert merge conflict --- .../schemas/projects_schema/schemas/schema_maya_create.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 8faf3fcae8..10d5bff445 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 @@ -12,9 +12,8 @@ "checkbox_key": "enabled", "children": [ { - "type": "boolean", - "key": "enabled", - "label": "Enabled" + "type": "label", + "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." }, { "type": "boolean", From b57c8a79c090332ce488dc6daad78c77f7d59240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:33:46 +0200 Subject: [PATCH 066/429] Remove extra whitespace --- openpype/hosts/houdini/plugins/create/create_vray_rop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py index 34c8906bb0..793a544fdf 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,7 +14,6 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - ext = "exr" def create(self, subset_name, instance_data, pre_create_data): From bb42369af27992a125ba7110954cd5a365cad542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:34:51 +0200 Subject: [PATCH 067/429] Fix merge conflict --- .../projects_schema/schemas/schema_maya_create.json | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 10d5bff445..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 @@ -12,8 +12,9 @@ "checkbox_key": "enabled", "children": [ { - "type": "label", - "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." + "type": "boolean", + "key": "enabled", + "label": "Enabled" }, { "type": "boolean", @@ -119,12 +120,10 @@ "collapsible": true, "key": "CreateAnimation", "label": "Create Animation", - "checkbox_key": "enabled", "children": [ { - "type": "boolean", - "key": "enabled", - "label": "Enabled" + "type": "label", + "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." }, { "type": "boolean", From 92254e2abc32d2305497dadf9319ebdef040ee14 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:53:32 +0200 Subject: [PATCH 068/429] Add a default variant to all Houdini creators --- 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 70c837205e..3d3b0e49b9 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -169,6 +169,8 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): selected_nodes = [] settings_name = None + default_variant = "Main" + def create(self, subset_name, instance_data, pre_create_data): try: self.selected_nodes = [] From 9604656017cf06fb444435dcd37c15e8681719ee Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 20:21:13 +0200 Subject: [PATCH 069/429] Add default variant on all creators --- .../defaults/project_settings/houdini.json | 29 +++++++++++++++---- .../defaults/project_settings/maya.json | 23 +++++++++++++++ .../schemas/schema_houdini_create.json | 5 ++++ .../schemas/schema_maya_create.json | 5 ++++ .../schemas/template_create_plugin.json | 5 ++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 389fdc6d96..cf5d1c93d5 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,80 +14,99 @@ "create": { "CreateArnoldAss": { "enabled": true, + "default_variant": "Main", "default_variants": [], "ext": ".ass" }, "CreateArnoldRop": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreateAlembicCamera": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateBGEO": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateCompositeSequence": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateHDA": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateKarmaROP": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreateMantraROP": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreatePointCache": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateRedshiftProxy": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreateRedshiftROP": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateRemotePublish": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateReview": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateUSD": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "CreateUSDModel": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "USDCreateShadingWorkspace": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "CreateUSDRender": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "CreateVDBCache": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateVrayROP": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b9817fe400..f53501c7ac 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,18 +521,21 @@ "enabled": true, "make_tx": true, "rs_tex": false, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRender": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateUnrealStaticMesh": { "enabled": true, + "default_variant": "", "default_variants": [ "", "_Main" @@ -547,6 +550,7 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, + "default_variant": "", "default_variants": [], "joint_hints": "jnt_org" }, @@ -560,6 +564,7 @@ "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, + "default_variant": "Main", "default_variants": [ "Main" ] @@ -568,6 +573,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "default_variant": "Main", "default_variants": [ "Main", "Proxy", @@ -579,6 +585,7 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, + "default_variant": "Main", "default_variants": [ "Main" ] @@ -587,12 +594,14 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateReview": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ], @@ -600,6 +609,7 @@ }, "CreateAss": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ], @@ -622,60 +632,70 @@ "enabled": true, "vrmesh": true, "alembic": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsd": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateAssembly": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateCamera": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateLayout": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMayaScene": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRig": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main", "Sim", @@ -684,6 +704,7 @@ }, "CreateSetDress": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main", "Anim" @@ -691,12 +712,14 @@ }, "CreateVRayScene": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": true, + "default_variant": "Main", "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 a1736c811d..6e1eaf7146 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 @@ -16,6 +16,11 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, { "type": "list", "key": "default_variants", 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 8dec0a8817..a8105bdb5d 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 @@ -387,6 +387,11 @@ "key": "alembic", "label": "Alembic" }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, { "type": "list", "key": "default_variants", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 3d2ed9f3d4..7384060625 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -11,6 +11,11 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, { "type": "list", "key": "default_variants", From 8a02654dbc133563fe57a0f7190fd7f72b2210ec Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 20:24:31 +0200 Subject: [PATCH 070/429] Fill up default variants --- .../defaults/project_settings/houdini.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index cf5d1c93d5..630b189743 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -15,7 +15,7 @@ "CreateArnoldAss": { "enabled": true, "default_variant": "Main", - "default_variants": [], + "default_variants": ["Main"], "ext": ".ass" }, "CreateArnoldRop": { @@ -26,22 +26,22 @@ "CreateAlembicCamera": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateBGEO": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateCompositeSequence": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateHDA": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateKarmaROP": { "enabled": true, @@ -56,7 +56,7 @@ "CreatePointCache": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateRedshiftProxy": { "enabled": true, @@ -66,42 +66,42 @@ "CreateRedshiftROP": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateRemotePublish": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateReview": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateUSD": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateUSDModel": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "USDCreateShadingWorkspace": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateUSDRender": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateVDBCache": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateVrayROP": { "enabled": true, From b1ac707a68335990970481885f7b3ac6bcbb87c5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 7 Aug 2023 22:11:01 +0300 Subject: [PATCH 071/429] update plugins for better demonstration --- .../houdini/plugins/create/create_fbx.py | 7 ++- .../hosts/houdini/plugins/load/load_fbx.py | 8 ++-- .../plugins/publish/collect_fbx_type.py | 22 ++++----- .../houdini/plugins/publish/extract_fbx.py | 48 +++++++++++++++---- .../publish/validate_fbx_hierarchy_path.py | 10 ++-- 5 files changed, 66 insertions(+), 29 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 9e1dbea2f4..2bfdb3e729 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -21,12 +21,15 @@ import hou class CreateFilmboxFBX(plugin.HoudiniCreator): - """Filmbox FBX Driver""" + """Filmbox FBX Driver.""" + + # you should set identifier = "io.openpype.creators.houdini.filmboxfbx" label = "Filmbox FBX" family = "filmboxfbx" icon = "fa5s.cubes" + # optional to set default_variant = "Main" default_variants = ["Main", "Test"] @@ -36,7 +39,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # set node type instance_data.update({"node_type": "filmboxfbx"}) - # create instance (calls super create method) + # create instance (calls HoudiniCreator.create()) instance = super(CreateFilmboxFBX, self).create( subset_name, instance_data, diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 3f66c8e88f..993b57ad21 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -22,9 +22,11 @@ class FbxLoader(load.LoaderPlugin): label = "Load FBX" families = ["filmboxfbx", "fbx"] representations = ["fbx"] - order = -10 # you can use this by default. - icon = "code-fork" # you can use this by default. - color = "orange" # you can use this by default. + + # Usually you will use these value as default + order = -10 + icon = "code-fork" + color = "orange" def load(self, context, name=None, namespace=None, data=None): diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index f1665b27f0..794d8bd6e7 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -1,10 +1,11 @@ """Collector for filmboxfbx types. -A Collector can act as a preprocessor for the validation stage. +Collectors act as a pre process for the validation stage. It is used mainly to update instance.data P.S. - There are some collectors that run by default for all types. + There are some collectors that run by default + for all types. This plugin is part of publish process guide. """ @@ -14,18 +15,21 @@ import pyblish.api class CollectFilmboxfbxType(pyblish.api.InstancePlugin): """Collect data type for filmboxfbx instance.""" - # Usually you will use this value as default - order = pyblish.api.CollectorOrder hosts = ["houdini"] families = ["filmboxfbx"] label = "Collect type of filmboxfbx" + # Usually you will use this value as default + order = pyblish.api.CollectorOrder + # overrides InstancePlugin.process() def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 # such a condition can be used to differentiate between - # instances by identifier even if they have the same type. + # instances by identifier becuase sometimes instances + # may have the same family but different identifier + # e.g. bgeo and alembic pass # Update instance.data with ouptut_node @@ -36,15 +40,11 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # Disclaimer : As a convntin we use collect_output_node.py # to Update instance.data with ouptut_node of different types - # however, we use this collector instead for demonstration + # however, this collector is used for demonstration def get_output_node(self, instance): - """Getting output_node Logic. - - It's moved here so that it become easier to focus on - process method. - """ + """Getting output_node Logic.""" import hou diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 6a4b541c33..102b075838 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -1,3 +1,11 @@ +"""Extract FilmBox FBX. + +Extractors are used to generate output and +update representation dictionary. + +This plugin is part of publish process guide. +""" + import os import pyblish.api @@ -10,31 +18,51 @@ import hou class ExtractRedshiftProxy(publish.Extractor): - # Usually you will use this value as default - order = pyblish.api.ExtractorOrder + 0.1 label = "Extract FilmBox FBX" families = ["filmboxfbx"] hosts = ["houdini"] + # Usually you will use this value as default + order = pyblish.api.ExtractorOrder + 0.1 + # overrides Extractor.process() def process(self, instance): + # get rop node ropnode = hou.node(instance.data.get("instance_node")) - # Get the filename from the filename parameter - # `.evalParm(parameter)` will make sure all tokens are resolved - output = ropnode.evalParm("sopoutput") - staging_dir = os.path.normpath(os.path.dirname(output)) + # render rop + render_rop(ropnode) + + # get required data + file_name, staging_dir = self.get_paths_data(ropnode) + representation = self.get_representation(instance, + file_name, + staging_dir) + + # set value type for 'representations' key to list + if "representations" not in instance.data: + instance.data["representations"] = [] + + # update instance data instance.data["stagingDir"] = staging_dir + instance.data["representations"].append(representation) + + def get_paths_data(self, ropnode): + # Get the filename from the filename parameter + output = ropnode.evalParm("sopoutput") + + staging_dir = os.path.normpath(os.path.dirname(output)) + file_name = os.path.basename(output) self.log.info("Writing FBX '%s' to '%s'" % (file_name, staging_dir)) - render_rop(ropnode) + return file_name, staging_dir - if "representations" not in instance.data: - instance.data["representations"] = [] + def get_representation(self, instance, + file_name, staging_dir): representation = { "name": "fbx", @@ -48,4 +76,4 @@ class ExtractRedshiftProxy(publish.Extractor): representation["frameStart"] = instance.data["frameStart"] representation["frameEnd"] = instance.data["frameEnd"] - instance.data["representations"].append(representation) + return representation diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 6a2bda1bca..e98e562fe8 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """Validate path attribute for all primitives. +Validators are used to verify the work of artists, +by running some checks which automates the approval process. + It's almost the same as 'validate_primitive_hierarchy_paths.py' however this one includes extra comments for demonstration. @@ -44,12 +47,13 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): by default. """ - # Usually you will use this value as default - order = ValidateContentsOrder + 0.1 families = ["filmboxfbx"] hosts = ["houdini"] label = "Validate FBX Hierarchy Path" + # Usually you will use this value as default + order = ValidateContentsOrder + 0.1 + # Validation can have as many actions as you want # all of these actions are defined in a seperate place # unlike the repair action @@ -69,7 +73,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): # This method was named get_invalid as a convention # it's also used by SelectInvalidAction to select - # the returned node + # the returned nodes @classmethod def get_invalid(cls, instance): From f41ad17b7bbf962baf209adcce243372ab7948bd Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 7 Aug 2023 22:22:47 +0300 Subject: [PATCH 072/429] update comments --- openpype/hosts/houdini/plugins/load/load_fbx.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 993b57ad21..5294f5248d 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -30,14 +30,19 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): + # get file path file_path = self.get_file_path(context) + # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) + # create load tree nodes = self.create_load_node_tree(file_path, node_name, name) self[:] = nodes + # Call containerise function which does some + # automations for you containerised_nodes = self.get_containerised_nodes( nodes, context, node_name, namespace ) @@ -96,7 +101,7 @@ class FbxLoader(load.LoaderPlugin): return namespace, node_name def create_load_node_tree(self, file_path, node_name, subset_name): - """Create Load node network. + """Create Load network. you can start building your tree at any obj level. it'll be much easier to build it in the root obj level. @@ -148,6 +153,12 @@ class FbxLoader(load.LoaderPlugin): return nodes def get_containerised_nodes(self, nodes, context, node_name, namespace): + """Call containerise function. + + It does some automations that you don't have to worry about, e.g. + 1. It moves created nodes to the AVALON_CONTAINERS subnetwork + 2. Add extra parameters + """ containerised_nodes = pipeline.containerise( node_name, namespace, From c72c8f332cbfbf3ded76ba9c6b2fcb9cbd9b1522 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 15:00:17 +0800 Subject: [PATCH 073/429] switching asset can maintain linkages on the modifiers and other data --- openpype/hosts/max/plugins/load/load_max_scene.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..c98b7909ee 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -24,7 +24,9 @@ class MaxSceneLoader(load.LoaderPlugin): path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path) + rt.MergeMaxFile( + path, rt.Name("autoRenameDups"), + includeFullGroup=True) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") for max_object in max_objects: @@ -38,11 +40,11 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) + merged_max_objects = rt.getLastMergedNodes() + rt.MergeMaxFile( + path, rt.Name("autoRenameDups"), + mergedNodes=merged_max_objects, + includeFullGroup=True) max_objects = rt.getLastMergedNodes() container_node = rt.GetNodeByName(node_name) From d51c1fa9fb3ba534d9b2ba5b0cd3c6254c465290 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 15:11:13 +0800 Subject: [PATCH 074/429] do not use autorename duplicate when updating or switching version --- openpype/hosts/max/plugins/load/load_max_scene.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index c98b7909ee..3af4613d1a 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -42,8 +42,7 @@ class MaxSceneLoader(load.LoaderPlugin): node_name = container["instance_node"] merged_max_objects = rt.getLastMergedNodes() rt.MergeMaxFile( - path, rt.Name("autoRenameDups"), - mergedNodes=merged_max_objects, + path, mergedNodes=merged_max_objects, includeFullGroup=True) max_objects = rt.getLastMergedNodes() From ab1172b277eaa396f3c3e992b3ad5759ff1635bf Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 15:54:35 +0800 Subject: [PATCH 075/429] delete old duplicates --- .../hosts/max/plugins/load/load_max_scene.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 3af4613d1a..92e1bdc59a 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -25,31 +25,27 @@ class MaxSceneLoader(load.LoaderPlugin): # import the max scene by using "merge file" path = path.replace('\\', '/') rt.MergeMaxFile( - path, rt.Name("autoRenameDups"), + path, rt.Name("mergeDups"), includeFullGroup=True) max_objects = rt.getLastMergedNodes() - max_container = rt.Container(name=f"{name}") - for max_object in max_objects: - max_object.Parent = max_container return containerise( - name, [max_container], context, loader=self.__class__.__name__) + name, [max_objects], context, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - node_name = container["instance_node"] - merged_max_objects = rt.getLastMergedNodes() + prev_max_objects = rt.getLastMergedNodes() + merged_max_objects = [obj.name for obj + in prev_max_objects] rt.MergeMaxFile( - path, mergedNodes=merged_max_objects, + path, merged_max_objects, + rt.Name("deleteOldDups"), + quiet=True, + mergedNodes=prev_max_objects, includeFullGroup=True) - max_objects = rt.getLastMergedNodes() - container_node = rt.GetNodeByName(node_name) - for max_object in max_objects: - max_object.Parent = container_node - lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 70cb60a17eb98ca7796674f4555f115019e27abe Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 17:19:42 +0800 Subject: [PATCH 076/429] deletion of the old objects --- openpype/hosts/max/plugins/load/load_max_scene.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 92e1bdc59a..1d105f1bc0 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -45,6 +45,11 @@ class MaxSceneLoader(load.LoaderPlugin): quiet=True, mergedNodes=prev_max_objects, includeFullGroup=True) + current_max_objects = rt.getLastMergedNodes() + for current_object in current_max_objects: + prev_max_objects = prev_max_objects.remove(current_object) + for prev_object in prev_max_objects: + rt.Delete(prev_object) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) From 58d2bcad88ca7ccd69a04fa6ec1e85dd693ac462 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 8 Aug 2023 12:07:57 +0200 Subject: [PATCH 077/429] Extract active view as thumbnail --- .../publish/extract_active_view_thumbnail.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py new file mode 100644 index 0000000000..1bf010896a --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -0,0 +1,51 @@ +import maya.api.OpenMaya as om +import maya.api.OpenMayaUI as omui + +import pyblish.api +import tempfile + + +class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): + """Set instance thumbnail to a screengrab of current active viewport. + + This makes it so that if an instance does not have a thumbnail set yet that + it will get a thumbnail of the currently active view at the time of + publishing as a fallback. + + """ + order = pyblish.api.ExtractorOrder + 0.49 + label = "Active View Thumbnail" + families = ["workfile"] + hosts = ["maya"] + + def process(self, instance): + thumbnail = instance.data.get("thumbnailPath") + if not thumbnail: + view_thumbnail = self.get_view_thumbnail(instance) + if not view_thumbnail: + return + + self.log.debug("Setting instance thumbnail path to: {}".format( + view_thumbnail + )) + instance.data["thumbnailPath"] = view_thumbnail + + def get_view_thumbnail(self, instance): + cache_key = "__maya_view_thumbnail" + context = instance.context + + if cache_key not in context.data: + # Generate only a single thumbnail, even for multiple instances + with tempfile.NamedTemporaryFile(suffix="_thumbnail.jpg", + delete=False) as f: + path = f.name + + view = omui.M3dView.active3dView() + image = om.MImage() + view.readColorBuffer(image, True) + image.writeToFile(path, "jpg") + self.log.debug("Generated thumbnail: {}".format(path)) + + context.data[cache_key] = path + return context.data[cache_key] + From 4049d9acb148e93f11849bc2395b904c0ff13dcf Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 8 Aug 2023 12:12:14 +0200 Subject: [PATCH 078/429] Cosmetics - good doggy! --- .../hosts/maya/plugins/publish/extract_active_view_thumbnail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py index 1bf010896a..cb039cbf51 100644 --- a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -48,4 +48,3 @@ class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): context.data[cache_key] = path return context.data[cache_key] - From 38b905c6f92b7ff4b3b9977c27b77153a35f9e8d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 18:27:13 +0800 Subject: [PATCH 079/429] restore the load max scene for resolving the possible conflict --- .../hosts/max/plugins/load/load_max_scene.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 468461bc0e..76cd3bf367 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,8 +1,7 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData - +from openpype.hosts.max.api.pipeline import containerise from openpype.pipeline import get_representation_path, load @@ -28,7 +27,6 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") - load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -41,16 +39,16 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - rt.MergeMaxFile(path) + rt.MergeMaxFile(path, + rt.Name("noRedraw"), + rt.Name("deleteOldDups"), + rt.Name("useSceneMtlDups")) max_objects = rt.getLastMergedNodes() container_node = rt.GetNodeByName(node_name) - instance_name, _ = os.path.splitext(node_name) - instance_container = rt.GetNodeByName(instance_name) for max_object in max_objects: - max_object.Parent = instance_container - instance_container.Parent = container_node - load_OpenpypeData(container_node, max_objects) + max_object.Parent = container_node + lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 20479595061f2e08c9ca3b3bb710cbe0f3147956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:06:56 +0200 Subject: [PATCH 080/429] Make use of `get_default_variant` function --- openpype/hosts/houdini/api/creator_node_shelves.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index c724acb16d..01da2fc3e1 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -196,13 +196,8 @@ def install(): key = "openpype_create.{}".format(identifier) log.debug(f"Registering {key}") - default_variant = "Main" - if hasattr(creator, "default_variant"): - default_variant = creator.default_variant - elif hasattr(creator, "default_variants"): - default_variant = creator.default_variants[0] script = CREATE_SCRIPT.format( - identifier=identifier, variant=default_variant + identifier=identifier, variant=creator.get_default_variant() ) data = { "script": script, From 2f663bc011ef4bd934f9cc7d690dfd3fd4769eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:20:08 +0200 Subject: [PATCH 081/429] Remove 'default_variant' from schemas --- .../defaults/project_settings/houdini.json | 57 +++++++------------ .../defaults/project_settings/maya.json | 24 -------- .../schemas/schema_houdini_create.json | 5 -- .../schemas/template_create_plugin.json | 5 -- 4 files changed, 19 insertions(+), 72 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 630b189743..512690bfd7 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,99 +14,80 @@ "create": { "CreateArnoldAss": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"], + "default_variants": ["main"], "ext": ".ass" }, "CreateArnoldRop": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreateAlembicCamera": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateBGEO": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateCompositeSequence": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateHDA": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateKarmaROP": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreateMantraROP": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreatePointCache": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateRedshiftProxy": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreateRedshiftROP": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateRemotePublish": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateReview": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateUSD": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateUSDModel": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "USDCreateShadingWorkspace": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateUSDRender": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateVDBCache": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateVrayROP": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index f53501c7ac..e1c6d2d827 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,21 +521,18 @@ "enabled": true, "make_tx": true, "rs_tex": false, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRender": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateUnrealStaticMesh": { "enabled": true, - "default_variant": "", "default_variants": [ "", "_Main" @@ -550,7 +547,6 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, - "default_variant": "", "default_variants": [], "joint_hints": "jnt_org" }, @@ -559,12 +555,10 @@ "publish_mip_map": true }, "CreateAnimation": { - "enabled": false, "write_color_sets": false, "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, - "default_variant": "Main", "default_variants": [ "Main" ] @@ -573,7 +567,6 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "default_variant": "Main", "default_variants": [ "Main", "Proxy", @@ -585,7 +578,6 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, - "default_variant": "Main", "default_variants": [ "Main" ] @@ -594,14 +586,12 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateReview": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ], @@ -609,7 +599,6 @@ }, "CreateAss": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ], @@ -632,70 +621,60 @@ "enabled": true, "vrmesh": true, "alembic": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsd": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateAssembly": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateCamera": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateLayout": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMayaScene": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRig": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main", "Sim", @@ -704,7 +683,6 @@ }, "CreateSetDress": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main", "Anim" @@ -712,14 +690,12 @@ }, "CreateVRayScene": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": true, - "default_variant": "Main", "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 6e1eaf7146..a1736c811d 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 @@ -16,11 +16,6 @@ "key": "enabled", "label": "Enabled" }, - { - "type": "text", - "key": "default_variant", - "label": "Default variant" - }, { "type": "list", "key": "default_variants", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 7384060625..3d2ed9f3d4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -11,11 +11,6 @@ "key": "enabled", "label": "Enabled" }, - { - "type": "text", - "key": "default_variant", - "label": "Default variant" - }, { "type": "list", "key": "default_variants", From d06b9dcdca4e6af00789a77806ed67f240bea4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:21:02 +0200 Subject: [PATCH 082/429] Remove fallback default_variant from Houdini base creator --- openpype/hosts/houdini/api/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 3d3b0e49b9..70c837205e 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -169,8 +169,6 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): selected_nodes = [] settings_name = None - default_variant = "Main" - def create(self, subset_name, instance_data, pre_create_data): try: self.selected_nodes = [] From 0703c1c0d18206966cbb37885f9dc96b0e4336aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:22:08 +0200 Subject: [PATCH 083/429] Remove default_variant from Maya schema --- .../schemas/projects_schema/schemas/schema_maya_create.json | 5 ----- 1 file changed, 5 deletions(-) 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 a8105bdb5d..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 @@ -387,11 +387,6 @@ "key": "alembic", "label": "Alembic" }, - { - "type": "text", - "key": "default_variant", - "label": "Default variant" - }, { "type": "list", "key": "default_variants", From 7b74d0b91ddaf0d1d8bda72a84d9cea87ba2f122 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 9 Aug 2023 20:21:25 +0800 Subject: [PATCH 084/429] add ornatrix alembic loader --- openpype/hosts/max/plugins/load/load_model.py | 2 +- .../max/plugins/load/load_model_ornatrix.py | 74 +++++++++++++++++++ .../hosts/max/plugins/load/load_pointcache.py | 2 +- .../plugins/load/load_pointcache_ornatrix.py | 70 ++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/max/plugins/load/load_model_ornatrix.py create mode 100644 openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index cff82a593c..c149f939a2 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -9,7 +9,7 @@ class ModelAbcLoader(load.LoaderPlugin): """Loading model with the Alembic loader.""" families = ["model"] - label = "Load Model(Alembic)" + label = "Load Model(Alembic) with Max" representations = ["abc"] order = -10 icon = "code-fork" diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py new file mode 100644 index 0000000000..d92e92f63b --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_model_ornatrix.py @@ -0,0 +1,74 @@ +import os +from openpype.pipeline import load, get_representation_path +from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api import lib + + +class ModelAbcLoader(load.LoaderPlugin): + """Loading model with the Ornatrix Alembic loader.""" + + families = ["model"] + label = "Load Model(Alembic) with Ornatrix" + representations = ["abc"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + from pymxs import runtime as rt + + file_path = os.path.normpath(self.filepath_from_context(context)) + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.AlembicImport.UVs = True + rt.AlembicImport.VertexColors = True + rt.importFile(file_path) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + abc_container = rt.Container(name=name) + for abc in scene_object_after: + abc.Parent = abc_container + + return containerise( + name, [abc_container], context, loader=self.__class__.__name__ + ) + + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node_name = container["instance_node"] + instance_name, _ = os.path.splitext(node_name) + container = rt.getNodeByName(instance_name) + for children in container.Children: + rt.Delete(children) + + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.AlembicImport.UVs = True + rt.AlembicImport.VertexColors = True + rt.importFile(path) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + for scene_object in scene_object_after: + scene_object.Parent = container + + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 290503e053..e59ad09c9f 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -14,7 +14,7 @@ class AbcLoader(load.LoaderPlugin): """Alembic loader.""" families = ["camera", "animation", "pointcache"] - label = "Load Alembic" + label = "Load Alembic with Max" representations = ["abc"] order = -10 icon = "code-fork" diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py new file mode 100644 index 0000000000..0b0932da6a --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -0,0 +1,70 @@ +import os +from openpype.pipeline import load, get_representation_path +from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api import lib + + +class ModelAbcLoader(load.LoaderPlugin): + """Ornatrix Alembic loader.""" + + families = ["camera", "animation", "pointcache"] + label = "Load Model(Alembic) with Ornatrix" + representations = ["abc"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + from pymxs import runtime as rt + + file_path = os.path.normpath(self.filepath_from_context(context)) + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.importFile(file_path) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + abc_container = rt.Container(name=name) + for abc in scene_object_after: + abc.Parent = abc_container + + return containerise( + name, [abc_container], context, loader=self.__class__.__name__ + ) + + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node_name = container["instance_node"] + instance_name, _ = os.path.splitext(node_name) + container = rt.getNodeByName(instance_name) + for children in container.Children: + rt.Delete(children) + + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = False + rt.AlembicImport.CustomAttributes = True + rt.importFile(path) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + for scene_object in scene_object_after: + scene_object.Parent = container + + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) From 8a175b06edb6b6ca8039ac6560c19541d923bd0f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 9 Aug 2023 20:29:20 +0800 Subject: [PATCH 085/429] rename the label --- openpype/hosts/max/plugins/load/load_model.py | 2 +- openpype/hosts/max/plugins/load/load_model_ornatrix.py | 2 +- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index c149f939a2..2f402efef8 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -9,7 +9,7 @@ class ModelAbcLoader(load.LoaderPlugin): """Loading model with the Alembic loader.""" families = ["model"] - label = "Load Model(Alembic) with Max" + label = "Load Model with Alembic" representations = ["abc"] order = -10 icon = "code-fork" diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py index d92e92f63b..8dc34278f5 100644 --- a/openpype/hosts/max/plugins/load/load_model_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_model_ornatrix.py @@ -8,7 +8,7 @@ class ModelAbcLoader(load.LoaderPlugin): """Loading model with the Ornatrix Alembic loader.""" families = ["model"] - label = "Load Model(Alembic) with Ornatrix" + label = "Load Model with Ornatrix Alembic" representations = ["abc"] order = -10 icon = "code-fork" diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 0b0932da6a..663c64bc0a 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -8,7 +8,7 @@ class ModelAbcLoader(load.LoaderPlugin): """Ornatrix Alembic loader.""" families = ["camera", "animation", "pointcache"] - label = "Load Model(Alembic) with Ornatrix" + label = "Load Alembic with Ornatrix" representations = ["abc"] order = -10 icon = "code-fork" From 938185c20835b545fa5c3b4b3e7673264509acf5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 9 Aug 2023 20:32:39 +0800 Subject: [PATCH 086/429] rename the class name --- openpype/hosts/max/plugins/load/load_model_ornatrix.py | 2 +- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py index 8dc34278f5..424820e6e6 100644 --- a/openpype/hosts/max/plugins/load/load_model_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_model_ornatrix.py @@ -4,7 +4,7 @@ from openpype.hosts.max.api.pipeline import containerise from openpype.hosts.max.api import lib -class ModelAbcLoader(load.LoaderPlugin): +class ModelOxAbcLoader(load.LoaderPlugin): """Loading model with the Ornatrix Alembic loader.""" families = ["model"] diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 663c64bc0a..0d04dbdef3 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -4,7 +4,7 @@ from openpype.hosts.max.api.pipeline import containerise from openpype.hosts.max.api import lib -class ModelAbcLoader(load.LoaderPlugin): +class OxAbcLoader(load.LoaderPlugin): """Ornatrix Alembic loader.""" families = ["camera", "animation", "pointcache"] From ef50ba5130d6be0b6f709ef60aa49181c67016a4 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Wed, 9 Aug 2023 15:25:48 +0200 Subject: [PATCH 087/429] Remove schema setting changes from PR --- .../defaults/project_settings/houdini.json | 76 ++++-------- .../defaults/project_settings/maya.json | 26 ++-- .../schemas/schema_houdini_create.json | 113 +++++++----------- .../schemas/schema_maya_create_render.json | 2 +- .../schemas/template_create_plugin.json | 4 +- 5 files changed, 79 insertions(+), 142 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 512690bfd7..a5256aad8b 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,80 +14,48 @@ "create": { "CreateArnoldAss": { "enabled": true, - "default_variants": ["main"], + "default_variants": [], "ext": ".ass" }, - "CreateArnoldRop": { - "enabled": true, - "default_variants": ["main"] - }, "CreateAlembicCamera": { "enabled": true, - "default_variants": ["main"] - }, - "CreateBGEO": { - "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreateCompositeSequence": { "enabled": true, - "default_variants": ["main"] - }, - "CreateHDA": { - "enabled": true, - "default_variants": ["main"] - }, - "CreateKarmaROP": { - "enabled": true, - "default_variants": ["main"] - }, - "CreateMantraROP": { - "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreatePointCache": { "enabled": true, - "default_variants": ["main"] - }, - "CreateRedshiftProxy": { - "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreateRedshiftROP": { "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreateRemotePublish": { "enabled": true, - "default_variants": ["main"] - }, - "CreateReview": { - "enabled": true, - "default_variants": ["main"] - }, - "CreateUSD": { - "enabled": false, - "default_variants": ["main"] - }, - "CreateUSDModel": { - "enabled": false, - "default_variants": ["main"] - }, - "USDCreateShadingWorkspace": { - "enabled": false, - "default_variants": ["main"] - }, - "CreateUSDRender": { - "enabled": false, - "default_variants": ["main"] + "defaults": [] }, "CreateVDBCache": { "enabled": true, - "default_variants": ["main"] + "defaults": [] }, - "CreateVrayROP": { - "enabled": true, - "default_variants": ["main"] + "CreateUSD": { + "enabled": false, + "defaults": [] + }, + "CreateUSDModel": { + "enabled": false, + "defaults": [] + }, + "USDCreateShadingWorkspace": { + "enabled": false, + "defaults": [] + }, + "CreateUSDRender": { + "enabled": false, + "defaults": [] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index e1c6d2d827..342d2bfb2a 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -527,7 +527,7 @@ }, "CreateRender": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, @@ -627,55 +627,55 @@ }, "CreateMultiverseUsd": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateAssembly": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateCamera": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateLayout": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateMayaScene": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateRig": { "enabled": true, - "default_variants": [ + "defaults": [ "Main", "Sim", "Cloth" @@ -683,20 +683,20 @@ }, "CreateSetDress": { "enabled": true, - "default_variants": [ + "defaults": [ "Main", "Anim" ] }, "CreateVRayScene": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateYetiRig": { "enabled": true, - "default_variants": [ + "defaults": [ "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 a1736c811d..4eb976d7b6 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 @@ -1,83 +1,60 @@ { - "type": "dict", - "collapsible": true, - "key": "create", - "label": "Creator plugins", - "children": [ - { - "type": "dict", - "collapsible": true, - "key": "CreateArnoldAss", - "label": "Create Arnold Ass", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "default_variants", - "label": "Default Subsets", - "object_type": "text" - }, - { - "type": "enum", - "key": "ext", - "label": "Default Output Format (extension)", - "multiselection": false, - "enum_items": [ - { - ".ass": ".ass" - }, - { - ".ass.gz": ".ass.gz (gzipped)" - } - ] - } - ] + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateArnoldAss", + "label": "Create Arnold Ass", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "enum", + "key": "ext", + "label": "Default Output Format (extension)", + "multiselection": false, + "enum_items": [ + { + ".ass": ".ass" + }, + { + ".ass.gz": ".ass.gz (gzipped)" + } + ] + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", "template_data": [ - { - "key": "CreateArnoldRop", - "label": "Create Arnold ROP" - }, { "key": "CreateAlembicCamera", "label": "Create Alembic Camera" }, - { - "key": "CreateBGEO", - "label": "Create Houdini BGEO" - }, { "key": "CreateCompositeSequence", "label": "Create Composite (Image Sequence)" }, - { - "key": "CreateKarmaROP", - "label": "Create Karma ROP" - }, - { - "key": "CreateMantraROP", - "label": "Create Mantra ROP" - }, - { - "key": "CreateHDA", - "label": "Create HDA" - }, { "key": "CreatePointCache", "label": "Create Point Cache" }, - { - "key": "CreateRedshiftProxy", - "label": "Create Redshift Proxy" - }, { "key": "CreateRedshiftROP", "label": "Create Redshift ROP" @@ -87,8 +64,8 @@ "label": "Create Remote Publish" }, { - "key": "CreateReview", - "label": "Create Review" + "key": "CreateVDBCache", + "label": "Create VDB Cache" }, { "key": "CreateUSD", @@ -105,14 +82,6 @@ { "key": "CreateUSDRender", "label": "Create USD Render" - }, - { - "key": "CreateVDBCache", - "label": "Create VDB Cache" - }, - { - "key": "CreateVrayROP", - "label": "Create VRay ROP" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json index 9d7432fe51..bc203a0514 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json @@ -12,7 +12,7 @@ }, { "type": "list", - "key": "default_variants", + "key": "defaults", "label": "Default Subsets", "object_type": "text" } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 3d2ed9f3d4..14d15e7840 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -13,8 +13,8 @@ }, { "type": "list", - "key": "default_variants", - "label": "Default Variants", + "key": "defaults", + "label": "Default Subsets", "object_type": "text" } ] From a9fd8349fc27b4744b19d6a125a25440de973917 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Wed, 9 Aug 2023 15:27:08 +0200 Subject: [PATCH 088/429] Remove whitespace differences --- .../schemas/schema_houdini_create.json | 170 +++++++++--------- 1 file changed, 85 insertions(+), 85 deletions(-) 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 4eb976d7b6..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 @@ -1,89 +1,89 @@ { - "type": "dict", - "collapsible": true, - "key": "create", - "label": "Creator plugins", - "children": [ - { - "type": "dict", - "collapsible": true, - "key": "CreateArnoldAss", - "label": "Create Arnold Ass", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "default_variants", - "label": "Default Subsets", - "object_type": "text" - }, - { - "type": "enum", - "key": "ext", - "label": "Default Output Format (extension)", - "multiselection": false, - "enum_items": [ - { - ".ass": ".ass" - }, - { - ".ass.gz": ".ass.gz (gzipped)" - } - ] - } - ] + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateArnoldAss", + "label": "Create Arnold Ass", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "enum", + "key": "ext", + "label": "Default Output Format (extension)", + "multiselection": false, + "enum_items": [ + { + ".ass": ".ass" + }, + { + ".ass.gz": ".ass.gz (gzipped)" + } + ] + } + ] - }, - { - "type": "schema_template", - "name": "template_create_plugin", - "template_data": [ - { - "key": "CreateAlembicCamera", - "label": "Create Alembic Camera" }, - { - "key": "CreateCompositeSequence", - "label": "Create Composite (Image Sequence)" - }, - { - "key": "CreatePointCache", - "label": "Create Point Cache" - }, - { - "key": "CreateRedshiftROP", - "label": "Create Redshift ROP" - }, - { - "key": "CreateRemotePublish", - "label": "Create Remote Publish" - }, - { - "key": "CreateVDBCache", - "label": "Create VDB Cache" - }, - { - "key": "CreateUSD", - "label": "Create USD" - }, - { - "key": "CreateUSDModel", - "label": "Create USD Model" - }, - { - "key": "USDCreateShadingWorkspace", - "label": "Create USD Shading Workspace" - }, - { - "key": "CreateUSDRender", - "label": "Create USD Render" - } - ] - } - ] + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateAlembicCamera", + "label": "Create Alembic Camera" + }, + { + "key": "CreateCompositeSequence", + "label": "Create Composite (Image Sequence)" + }, + { + "key": "CreatePointCache", + "label": "Create Point Cache" + }, + { + "key": "CreateRedshiftROP", + "label": "Create Redshift ROP" + }, + { + "key": "CreateRemotePublish", + "label": "Create Remote Publish" + }, + { + "key": "CreateVDBCache", + "label": "Create VDB Cache" + }, + { + "key": "CreateUSD", + "label": "Create USD" + }, + { + "key": "CreateUSDModel", + "label": "Create USD Model" + }, + { + "key": "USDCreateShadingWorkspace", + "label": "Create USD Shading Workspace" + }, + { + "key": "CreateUSDRender", + "label": "Create USD Render" + } + ] + } + ] } From cb48a2268c996ade66744540c2019b3eb8d06e98 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 10 Aug 2023 00:19:52 +0800 Subject: [PATCH 089/429] add the using alembic import back to the importfile --- openpype/hosts/max/plugins/load/load_model.py | 3 +- .../max/plugins/load/load_model_ornatrix.py | 74 ------------------- .../hosts/max/plugins/load/load_pointcache.py | 3 +- .../plugins/load/load_pointcache_ornatrix.py | 70 ------------------ 4 files changed, 4 insertions(+), 146 deletions(-) delete mode 100644 openpype/hosts/max/plugins/load/load_model_ornatrix.py delete mode 100644 openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index 2f402efef8..288fc58454 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -30,7 +30,8 @@ class ModelAbcLoader(load.LoaderPlugin): rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile( + file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py deleted file mode 100644 index 424820e6e6..0000000000 --- a/openpype/hosts/max/plugins/load/load_model_ornatrix.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise -from openpype.hosts.max.api import lib - - -class ModelOxAbcLoader(load.LoaderPlugin): - """Loading model with the Ornatrix Alembic loader.""" - - families = ["model"] - label = "Load Model with Ornatrix Alembic" - representations = ["abc"] - order = -10 - icon = "code-fork" - color = "orange" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - - file_path = os.path.normpath(self.filepath_from_context(context)) - scene_object_before = [obj for obj in rt.rootNode.Children] - rt.AlembicImport.ImportToRoot = True - rt.AlembicImport.CustomAttributes = True - rt.AlembicImport.UVs = True - rt.AlembicImport.VertexColors = True - rt.importFile(file_path) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - - abc_container = rt.Container(name=name) - for abc in scene_object_after: - abc.Parent = abc_container - - return containerise( - name, [abc_container], context, loader=self.__class__.__name__ - ) - - def update(self, container, representation): - from pymxs import runtime as rt - - path = get_representation_path(representation) - node_name = container["instance_node"] - instance_name, _ = os.path.splitext(node_name) - container = rt.getNodeByName(instance_name) - for children in container.Children: - rt.Delete(children) - - scene_object_before = [obj for obj in rt.rootNode.Children] - rt.AlembicImport.ImportToRoot = True - rt.AlembicImport.CustomAttributes = True - rt.AlembicImport.UVs = True - rt.AlembicImport.VertexColors = True - rt.importFile(path) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - - for scene_object in scene_object_after: - scene_object.Parent = container - - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) - - def switch(self, container, representation): - self.update(container, representation) - - def remove(self, container): - from pymxs import runtime as rt - - node = rt.GetNodeByName(container["instance_node"]) - rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index e59ad09c9f..24bcf58582 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -33,7 +33,8 @@ class AbcLoader(load.LoaderPlugin): } rt.AlembicImport.ImportToRoot = False - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile( + file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py deleted file mode 100644 index 0d04dbdef3..0000000000 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise -from openpype.hosts.max.api import lib - - -class OxAbcLoader(load.LoaderPlugin): - """Ornatrix Alembic loader.""" - - families = ["camera", "animation", "pointcache"] - label = "Load Alembic with Ornatrix" - representations = ["abc"] - order = -10 - icon = "code-fork" - color = "orange" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - - file_path = os.path.normpath(self.filepath_from_context(context)) - scene_object_before = [obj for obj in rt.rootNode.Children] - rt.AlembicImport.ImportToRoot = True - rt.AlembicImport.CustomAttributes = True - rt.importFile(file_path) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - - abc_container = rt.Container(name=name) - for abc in scene_object_after: - abc.Parent = abc_container - - return containerise( - name, [abc_container], context, loader=self.__class__.__name__ - ) - - def update(self, container, representation): - from pymxs import runtime as rt - - path = get_representation_path(representation) - node_name = container["instance_node"] - instance_name, _ = os.path.splitext(node_name) - container = rt.getNodeByName(instance_name) - for children in container.Children: - rt.Delete(children) - - scene_object_before = [obj for obj in rt.rootNode.Children] - rt.AlembicImport.ImportToRoot = False - rt.AlembicImport.CustomAttributes = True - rt.importFile(path) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - - for scene_object in scene_object_after: - scene_object.Parent = container - - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) - - def switch(self, container, representation): - self.update(container, representation) - - def remove(self, container): - from pymxs import runtime as rt - - node = rt.GetNodeByName(container["instance_node"]) - rt.Delete(node) From 5cb43a141476fbba18af9a36df747d6082c31036 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 10 Aug 2023 00:30:06 +0800 Subject: [PATCH 090/429] restore the label name --- openpype/hosts/max/plugins/load/load_pointcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 24bcf58582..f250377cfd 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -14,7 +14,7 @@ class AbcLoader(load.LoaderPlugin): """Alembic loader.""" families = ["camera", "animation", "pointcache"] - label = "Load Alembic with Max" + label = "Load Alembic" representations = ["abc"] order = -10 icon = "code-fork" From 0a69439f2ef257e9800eda5e88d925b258f2ee25 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 10 Aug 2023 01:00:34 +0800 Subject: [PATCH 091/429] supports loading ornatrix alembic --- .../max/plugins/load/load_model_ornatrix.py | 74 +++++++++++++++++++ .../plugins/load/load_pointcache_ornatrix.py | 70 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 openpype/hosts/max/plugins/load/load_model_ornatrix.py create mode 100644 openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py new file mode 100644 index 0000000000..92773ad567 --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_model_ornatrix.py @@ -0,0 +1,74 @@ +import os +from openpype.pipeline import load, get_representation_path +from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api import lib + + +class OxModelAbcLoader(load.LoaderPlugin): + """Loading model with the Ornatrix Alembic loader.""" + + families = ["model"] + label = "Load Model with Ornatrix Alembic" + representations = ["abc"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + from pymxs import runtime as rt + + file_path = os.path.normpath(self.filepath_from_context(context)) + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.AlembicImport.UVs = True + rt.AlembicImport.VertexColors = True + rt.importFile(file_path, rt.name("noPrompt")) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + abc_container = rt.Container(name=name) + for abc in scene_object_after: + abc.Parent = abc_container + + return containerise( + name, [abc_container], context, loader=self.__class__.__name__ + ) + + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node_name = container["instance_node"] + instance_name, _ = os.path.splitext(node_name) + container = rt.getNodeByName(instance_name) + for children in container.Children: + rt.Delete(children) + + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.AlembicImport.UVs = True + rt.AlembicImport.VertexColors = True + rt.importFile(path) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + for scene_object in scene_object_after: + scene_object.Parent = container + + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py new file mode 100644 index 0000000000..cd08e9a2ff --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -0,0 +1,70 @@ +import os +from openpype.pipeline import load, get_representation_path +from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api import lib + + +class OxAbcLoader(load.LoaderPlugin): + """Ornatrix Alembic loader.""" + + families = ["camera", "animation", "pointcache"] + label = "Load Alembic with Ornatrix" + representations = ["abc"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + from pymxs import runtime as rt + + file_path = os.path.normpath(self.filepath_from_context(context)) + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.importFile(file_path, rt.name("noPrompt")) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + abc_container = rt.Container(name=name) + for abc in scene_object_after: + abc.Parent = abc_container + + return containerise( + name, [abc_container], context, loader=self.__class__.__name__ + ) + + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node_name = container["instance_node"] + instance_name, _ = os.path.splitext(node_name) + container = rt.getNodeByName(instance_name) + for children in container.Children: + rt.Delete(children) + + scene_object_before = [obj for obj in rt.rootNode.Children] + rt.AlembicImport.ImportToRoot = False + rt.AlembicImport.CustomAttributes = True + rt.importFile(path) + scene_object_after = [obj for obj in rt.rootNode.Children] + for scene_object in scene_object_before: + scene_object_after = scene_object_after.remove(scene_object) + + for scene_object in scene_object_after: + scene_object.Parent = container + + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) From c8e66fd632323416a63c0c9d1bf248e516f2c2be Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 9 Aug 2023 20:34:05 +0300 Subject: [PATCH 092/429] resolve some conversations --- .../publish/validate_review_colorspace.py | 16 ++++++++-------- openpype/pipeline/colorspace.py | 4 +++- openpype/scripts/ocio_wrapper.py | 4 +++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 67e29e0ee2..e493349946 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -4,6 +4,9 @@ from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction +import os +import hou + class SetDefaultViewSpaceAction(RepairAction): label = "Set default view space" @@ -32,8 +35,6 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - import hou # noqa - import os rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: @@ -70,16 +71,15 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): used to set colorspace on opengl node to the default view. """ - import hou - from openpype.pipeline.colorspace import get_display_view_colorspace_name #noqa - from openpype.hosts.houdini.api.lib import get_color_management_preferences #noqa + from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa + from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa rop_node = hou.node(instance.data["instance_node"]) - data = get_color_management_preferences() + data = get_color_management_preferences() config_path = data.get("config") - display = data.get("display") - view = data.get("view") + display = data.get("display") + view = data.get("view") cls.log.debug("Get default view colorspace name..") diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 22e8175a7e..d84424270c 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -590,6 +590,7 @@ def _get_imageio_settings(project_settings, host_name): return imageio_global, imageio_host + def get_display_view_colorspace_name(config_path, display, view): """get view colorspace name for the given display and view. @@ -608,10 +609,11 @@ def get_display_view_colorspace_name(config_path, display, view): return get_display_view_colorspace_subprocess(config_path, display, view) - from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name #noqa + from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name # noqa return _get_display_view_colorspace_name(config_path, display, view) + def get_display_view_colorspace_subprocess(config_path, display, view): """get view colorspace name for the given display and view via subprocess. diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index f94faabe11..556568ce20 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -173,6 +173,7 @@ def _get_views_data(config_path): return data + def _get_display_view_colorspace_name(config_path, display, view): """get view colorspace name for the given display and view. @@ -199,6 +200,7 @@ def _get_display_view_colorspace_name(config_path, display, view): return colorspace + @config.command( name="get_display_view_colorspace_name", help=( @@ -223,7 +225,7 @@ def get_display_view_colorspace_name(in_path, out_path, display, view): """Aggregate view colorspace name to file. - Python 2 wrapped console command + Wrapper command for processes without acces to OpenColorIO Args: in_path (str): config file path string From 877facc5b89317a0fc541d2f06a9d9611c9e1acb Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 9 Aug 2023 21:56:42 +0300 Subject: [PATCH 093/429] set default colorspace on creation if there's OCIO --- .../houdini/plugins/create/create_review.py | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index ab06b30c35..797116aaca 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -3,6 +3,9 @@ from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef +import os +import hou + class CreateReview(plugin.HoudiniCreator): """Review with OpenGL ROP""" @@ -13,7 +16,6 @@ class CreateReview(plugin.HoudiniCreator): icon = "video-camera" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) instance_data.update({"node_type": "opengl"}) @@ -82,6 +84,10 @@ class CreateReview(plugin.HoudiniCreator): instance_node.setParms(parms) + # Set OCIO Colorspace to the default output colorspace + # if there's OCIO + self.set_colorcorrect_to_default_view_space(instance_node) + to_lock = ["id", "family"] self.lock_parameters(instance_node, to_lock) @@ -123,3 +129,46 @@ class CreateReview(plugin.HoudiniCreator): minimum=0.0001, decimals=3) ] + + def set_colorcorrect_to_default_view_space(self, + instance_node): + """Set ociocolorspace to the default output space.""" + + if os.getenv("OCIO") is None: + # No OCIO, skip setting ociocolorspace + return + + # if there's OCIO then set Color Correction parameter + # to OpenColorIO + instance_node.setParms({"colorcorrect": 2}) + + self.log.debug("Get default view colorspace name..") + + default_view_space = self.get_default_view_space() + instance_node.setParms( + {"ociocolorspace": default_view_space} + ) + + self.log.debug( + "'OCIO Colorspace' parm on '{}' has been set to " + "the default view color space '{}'" + .format(instance_node, default_view_space) + ) + + return default_view_space + + def get_default_view_space(self): + """Get default view space for ociocolorspace parm.""" + + from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa + from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa + + data = get_color_management_preferences() + config_path = data.get("config") + display = data.get("display") + view = data.get("view") + + default_view_space = get_display_view_colorspace_name(config_path, + display, view) + + return default_view_space From cff92425676acef62fc6b8489517e66286072925 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 9 Aug 2023 21:57:57 +0300 Subject: [PATCH 094/429] use .format instead of %s --- .../publish/validate_review_colorspace.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index e493349946..5390b6b52f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -49,17 +49,17 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): # 'Color Correct' parm to be set to 'OpenColorIO' rop_node.setParms({"colorcorrect": 2}) cls.log.debug( - "'Color Correct' parm on '%s' has been set to" - " 'OpenColorIO'", rop_node + "'Color Correct' parm on '{}' has been set to" + " 'OpenColorIO'".format(rop_node) ) if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): cls.log.error( - "'OCIO Colorspace' value on '%s' is not valid, " - "select a valid option from the dropdown menu.", - rop_node + "'OCIO Colorspace' value on '{}' is not valid, " + "select a valid option from the dropdown menu." + .format(rop_node) ) return rop_node @@ -88,7 +88,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( - "'OCIO Colorspace' parm on '%s' has been set to " - "the default view color space '%s'", - default_view_space, rop_node + "'OCIO Colorspace' parm on '{}' has been set to " + "the default view color space '{}'" + .formate(rop_node, default_view_space) + ) From 17904600a00dbb64875ce06fd07d3d6d220ec8ca Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 10 Aug 2023 14:56:23 +0800 Subject: [PATCH 095/429] add using ornatrix alembic importer into the ornatrix alembic loaders --- openpype/hosts/max/plugins/load/load_model_ornatrix.py | 4 +++- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py index 92773ad567..38c081b91c 100644 --- a/openpype/hosts/max/plugins/load/load_model_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_model_ornatrix.py @@ -23,7 +23,9 @@ class OxModelAbcLoader(load.LoaderPlugin): rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile( + file_path, rt.name("noPrompt"), + using=rt.Ornatrix_Alembic_Importer) scene_object_after = [obj for obj in rt.rootNode.Children] for scene_object in scene_object_before: scene_object_after = scene_object_after.remove(scene_object) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index cd08e9a2ff..65a8273ab5 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -21,7 +21,9 @@ class OxAbcLoader(load.LoaderPlugin): scene_object_before = [obj for obj in rt.rootNode.Children] rt.AlembicImport.ImportToRoot = True rt.AlembicImport.CustomAttributes = True - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile( + file_path, rt.name("noPrompt"), + using=rt.Ornatrix_Alembic_Importer) scene_object_after = [obj for obj in rt.rootNode.Children] for scene_object in scene_object_before: scene_object_after = scene_object_after.remove(scene_object) From a3c761c0683c25ab5f4a901d06a3f955ffcd8d10 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 10 Aug 2023 15:36:59 +0800 Subject: [PATCH 096/429] add using ornatrix alembic importer into the ornatrix alembic loaders when updating version --- openpype/hosts/max/plugins/load/load_model_ornatrix.py | 4 +++- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py index 38c081b91c..07633ec55d 100644 --- a/openpype/hosts/max/plugins/load/load_model_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_model_ornatrix.py @@ -53,7 +53,9 @@ class OxModelAbcLoader(load.LoaderPlugin): rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True - rt.importFile(path) + rt.importFile( + path, rt.name("noPrompt"), + using=rt.Ornatrix_Alembic_Importer) scene_object_after = [obj for obj in rt.rootNode.Children] for scene_object in scene_object_before: scene_object_after = scene_object_after.remove(scene_object) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 65a8273ab5..f783583ff1 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -49,7 +49,9 @@ class OxAbcLoader(load.LoaderPlugin): scene_object_before = [obj for obj in rt.rootNode.Children] rt.AlembicImport.ImportToRoot = False rt.AlembicImport.CustomAttributes = True - rt.importFile(path) + rt.importFile( + path, rt.name("noPrompt"), + using=rt.Ornatrix_Alembic_Importer) scene_object_after = [obj for obj in rt.rootNode.Children] for scene_object in scene_object_before: scene_object_after = scene_object_after.remove(scene_object) From f79f0c44da2e486887f0078c7e3d4dcfa2d4e007 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 11 Aug 2023 12:27:09 +0200 Subject: [PATCH 097/429] Add to cleanup files Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../hosts/maya/plugins/publish/extract_active_view_thumbnail.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py index cb039cbf51..b4e62f8acc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -46,5 +46,6 @@ class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): image.writeToFile(path, "jpg") self.log.debug("Generated thumbnail: {}".format(path)) + context.data["cleanupFullPaths"].append(path) context.data[cache_key] = path return context.data[cache_key] From 67840465ab7cdd2c9f282b13324d53620cbce60d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 14 Aug 2023 14:36:35 +0800 Subject: [PATCH 098/429] update the attribute after OP Param update --- openpype/hosts/max/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 82470dd510..36c29ddbbb 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -194,4 +194,4 @@ def load_OpenpypeData(container, loaded_nodes): # Setting the property rt.setProperty( - container.openPypeData, "all_handles", node_list) + container.modifiers[0].openPypeData, "all_handles", node_list) From cb086d113ec10131663d17d3a9e06495558f840d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 14 Aug 2023 18:06:23 +0800 Subject: [PATCH 099/429] clean up the load OpenpypeData code --- openpype/hosts/max/api/pipeline.py | 25 ++++--------------- .../hosts/max/plugins/load/load_camera_fbx.py | 4 +-- openpype/hosts/max/plugins/load/load_model.py | 11 +++----- .../hosts/max/plugins/load/load_model_fbx.py | 4 +-- .../hosts/max/plugins/load/load_model_obj.py | 4 +-- .../hosts/max/plugins/load/load_model_usd.py | 6 ++--- .../hosts/max/plugins/load/load_pointcache.py | 8 ++---- .../hosts/max/plugins/load/load_pointcloud.py | 4 +-- .../max/plugins/load/load_redshift_proxy.py | 4 +-- 9 files changed, 22 insertions(+), 48 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 36c29ddbbb..602b506ef0 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -174,24 +174,9 @@ def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): return container -def load_OpenpypeData(container, loaded_nodes): - """Function to load the OpenpypeData Parameter along with - the published objects - - Args: - container (str): target container to set up - the custom attributes - loaded_nodes (list): list of nodes to be loaded +def load_OpenpypeData(): + """Re-loading the Openpype parameter built by the creator + Returns: + attribute: re-loading the custom OP attributes set in Maxscript """ - attrs = rt.Execute(MS_CUSTOM_ATTRIB) - if rt.custAttributes.get(container.baseObject, attrs): - rt.custAttributes.delete(container.baseObject, attrs) - rt.custAttributes.add(container.baseObject, attrs) - node_list = [] - for i in loaded_nodes: - node_ref = rt.NodeTransformMonitor(node=i) - node_list.append(node_ref) - - # Setting the property - rt.setProperty( - container.modifiers[0].openPypeData, "all_handles", node_list) + return rt.Execute(MS_CUSTOM_ATTRIB) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 6b16bfe474..e7aa482b2e 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -33,7 +33,7 @@ class FbxLoader(load.LoaderPlugin): container = rt.Container() container.name = f"{name}" selections = rt.GetCurrentSelection() - load_OpenpypeData(container, selections) + load_OpenpypeData() for selection in selections: selection.Parent = container @@ -52,7 +52,7 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData(node, node.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index efd758063d..e987e5e900 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -45,10 +45,7 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - selections = rt.GetCurrentSelection() - abc_selections = [abc for abc in selections - if abc.name != "Alembic"] - load_OpenpypeData(abc_container, abc_selections) + load_OpenpypeData() return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -61,7 +58,6 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node.Children) nodes_list = [] - abc_object = None with maintained_selection(): rt.Select(node) @@ -77,9 +73,8 @@ class ModelAbcLoader(load.LoaderPlugin): alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) - abc_selections = [abc for abc in nodes_list - if abc.name != "Alembic"] - load_OpenpypeData(abc_object, abc_selections) + + load_OpenpypeData() lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 8f2b4f4ac3..76c2639388 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -29,7 +29,7 @@ class FbxModelLoader(load.LoaderPlugin): container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData(container, selections) + load_OpenpypeData() for selection in selections: selection.Parent = container @@ -50,7 +50,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData(container, node.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 83b5ec49b9..5a7181f438 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -26,7 +26,7 @@ class ObjLoader(load.LoaderPlugin): container = rt.Container() container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData(container, selections) + load_OpenpypeData() # get current selection for selection in selections: selection.Parent = container @@ -53,7 +53,7 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.Parent = container - load_OpenpypeData(container, container.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index a1961e6d89..0e275dd02e 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -30,10 +30,8 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - selections = rt.GetCurrentSelection() asset = rt.GetNodeByName(name) - mesh_selections = [r for r in selections if r != asset] - load_OpenpypeData(asset, mesh_selections) + load_OpenpypeData() return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -62,7 +60,7 @@ class ModelUSDLoader(load.LoaderPlugin): asset = rt.GetNodeByName(instance_name) asset.Parent = node - load_OpenpypeData(asset, asset.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 7af588566e..dda57add69 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -49,9 +49,7 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - abc_selections = [abc for abc in selections - if abc.name != "Alembic"] - load_OpenpypeData(abc_container, abc_selections) + load_OpenpypeData() for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -72,7 +70,6 @@ class AbcLoader(load.LoaderPlugin): {"representation": str(representation["_id"])}, ) nodes_list = [] - abc_object = None with maintained_selection(): rt.Select(node.Children) @@ -88,8 +85,7 @@ class AbcLoader(load.LoaderPlugin): alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) - abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] - load_OpenpypeData(abc_object, abc_selections) + load_OpenpypeData() def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 18998f4529..8ab81d79e7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -25,7 +25,7 @@ class PointCloudLoader(load.LoaderPlugin): prt_container = rt.container() prt_container.name = name obj.Parent = prt_container - load_OpenpypeData(prt_container, [obj]) + load_OpenpypeData() return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -41,7 +41,7 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - load_OpenpypeData(node, node.Children) + load_OpenpypeData() lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index b62400d2e5..23f78d0629 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -33,7 +33,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - load_OpenpypeData(container, [rs_proxy]) + load_OpenpypeData() asset = rt.getNodeByName(name) return containerise( @@ -49,7 +49,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path - load_OpenpypeData(node, node.Children) + load_OpenpypeData() lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From fdad1a48b0cc0f460df53b6c9dc101a24b98d656 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 14 Aug 2023 18:09:02 +0800 Subject: [PATCH 100/429] Hound --- openpype/hosts/max/plugins/load/load_model.py | 2 -- openpype/hosts/max/plugins/load/load_pointcache.py | 1 - 2 files changed, 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index e987e5e900..7ba048c5e7 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -60,13 +60,11 @@ class ModelAbcLoader(load.LoaderPlugin): nodes_list = [] with maintained_selection(): rt.Select(node) - for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) - abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index dda57add69..ec379e39f7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -78,7 +78,6 @@ class AbcLoader(load.LoaderPlugin): rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) - abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: From 256ffee407e8718ca6099c5f7185d0ec88a0ace7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 14 Aug 2023 15:54:14 +0300 Subject: [PATCH 101/429] resolve some conversations --- openpype/pipeline/colorspace.py | 2 +- openpype/scripts/ocio_wrapper.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 392089237b..a0efb5e18c 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -604,7 +604,7 @@ def _get_imageio_settings(project_settings, host_name): def get_display_view_colorspace_name(config_path, display, view): - """get view colorspace name for the given display and view. + """Return colorspace name for the given display and view. Args: config_path (str): path string leading to config.ocio diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 556568ce20..e491206ebb 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -247,7 +247,7 @@ def get_display_view_colorspace_name(in_path, out_path, with open(json_path, "w") as f: json.dump(out_data, f) - print(f"Viewer data are saved to '{json_path}'") + print(f"Display view colorspace saved to '{json_path}'") if __name__ == '__main__': main() From e666ff641b4e80dc5aaff837fffd2b0f9651f6c2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 15 Aug 2023 17:14:42 +0800 Subject: [PATCH 102/429] reload the modifiers to the container with OP Data --- openpype/hosts/max/api/pipeline.py | 27 +++++++++++++++++++ .../hosts/max/plugins/load/load_camera_fbx.py | 18 +++++++------ openpype/hosts/max/plugins/load/load_model.py | 11 ++++---- .../hosts/max/plugins/load/load_model_fbx.py | 13 ++++++--- .../hosts/max/plugins/load/load_model_obj.py | 6 ++--- .../hosts/max/plugins/load/load_model_usd.py | 8 +++--- .../hosts/max/plugins/load/load_pointcache.py | 11 +++++--- .../hosts/max/plugins/load/load_pointcloud.py | 8 +++--- .../max/plugins/load/load_redshift_proxy.py | 8 +++--- 9 files changed, 78 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 602b506ef0..6b02f06b85 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -180,3 +180,30 @@ def load_OpenpypeData(): attribute: re-loading the custom OP attributes set in Maxscript """ return rt.Execute(MS_CUSTOM_ATTRIB) + + +def import_OpenpypeData(container, selections): + attrs = load_OpenpypeData() + modifier = rt.EmptyModifier() + rt.addModifier(container, modifier) + container.modifiers[0].name = "OP Data" + rt.custAttributes.add(container.modifiers[0], attrs) + node_list = [] + sel_list = [] + for i in selections: + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + sel_list.append(str(i)) + # Setting the property + rt.setProperty( + container.modifiers[0].openPypeData, + "all_handles", node_list) + rt.setProperty( + container.modifiers[0].openPypeData, + "sel_list", sel_list) + + +def update_Openpype_Data(container, selections): + if container.modifiers[0].name == "OP Data": + rt.deleteModifier(container, container.modifiers[0]) + import_OpenpypeData(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index e7aa482b2e..7bd02e4615 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.pipeline import get_representation_path, load @@ -16,24 +18,21 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(f"{name}") - if not container: - container = rt.Container() - container.name = f"{name}" + container = rt.container(name=name) selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(container, selections) for selection in selections: selection.Parent = container @@ -45,14 +44,17 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) + inst_name, _ = os.path.split(container["instance_node"]) + container = rt.getNodeByName(inst_name) rt.Select(node.Children) + update_Openpype_Data(container, rt.GetCurrentSelection()) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) + rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index 7ba048c5e7..ea60c33c19 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,6 +1,8 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -30,7 +32,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c @@ -45,7 +47,7 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - load_OpenpypeData() + import_OpenpypeData(abc_container, abc_container.Children) return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -62,6 +64,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) + import_OpenpypeData(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) @@ -72,8 +75,6 @@ class ModelAbcLoader(load.LoaderPlugin): alembic_obj.source = path nodes_list.append(alembic_obj) - load_OpenpypeData() - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 76c2639388..9f80875d5b 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,6 +1,8 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -20,6 +22,7 @@ class FbxModelLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) @@ -29,7 +32,7 @@ class FbxModelLoader(load.LoaderPlugin): container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(container, selections) for selection in selections: selection.Parent = container @@ -42,15 +45,19 @@ class FbxModelLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) + inst_name, _ = os.path.splitext(container["instance_node"]) rt.select(node.Children) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) + rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData() + + container = rt.getNodeByName(inst_name) + update_Openpype_Data(container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 5a7181f438..f4791bfbb3 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,7 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import containerise, import_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -26,7 +26,7 @@ class ObjLoader(load.LoaderPlugin): container = rt.Container() container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(container, selections) # get current selection for selection in selections: selection.Parent = container @@ -53,7 +53,7 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.Parent = container - load_OpenpypeData() + import_OpenpypeData(container, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 0e275dd02e..96b5cdedf0 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.pipeline import get_representation_path, load @@ -31,7 +33,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile(filepath, importOptions=import_options) asset = rt.GetNodeByName(name) - load_OpenpypeData() + import_OpenpypeData(asset, asset.Children) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -60,7 +62,7 @@ class ModelUSDLoader(load.LoaderPlugin): asset = rt.GetNodeByName(instance_name) asset.Parent = node - load_OpenpypeData() + update_Openpype_Data(asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index ec379e39f7..18a68732e9 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,7 +7,9 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) class AbcLoader(load.LoaderPlugin): @@ -33,7 +35,7 @@ class AbcLoader(load.LoaderPlugin): } rt.AlembicImport.ImportToRoot = False - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c @@ -49,7 +51,7 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(abc_container, abc_container.Children) for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -75,6 +77,7 @@ class AbcLoader(load.LoaderPlugin): for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) + update_Openpype_Data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) @@ -84,7 +87,7 @@ class AbcLoader(load.LoaderPlugin): alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) - load_OpenpypeData() + def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 8ab81d79e7..2f41173bce 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.pipeline import get_representation_path, load @@ -25,7 +27,7 @@ class PointCloudLoader(load.LoaderPlugin): prt_container = rt.container() prt_container.name = name obj.Parent = prt_container - load_OpenpypeData() + import_OpenpypeData(prt_container, [obj]) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -41,7 +43,7 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - load_OpenpypeData() + update_Openpype_Data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 23f78d0629..4b488bcb7c 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,7 +5,9 @@ from openpype.pipeline import ( load, get_representation_path ) -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.hosts.max.api import lib @@ -33,7 +35,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - load_OpenpypeData() + import_OpenpypeData(container, [rs_proxy]) asset = rt.getNodeByName(name) return containerise( @@ -49,7 +51,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path - load_OpenpypeData() + update_Openpype_Data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 5f54f9082477162d65d9944365a285e732fad0fd Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 15 Aug 2023 17:16:14 +0800 Subject: [PATCH 103/429] reload the moddifier with OP Data in load model --- openpype/hosts/max/plugins/load/load_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index ea60c33c19..e1978e35ad 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -64,7 +64,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - import_OpenpypeData(abc, abc.Children) + update_Openpype_Data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) From cf4ce6bbc5d2684f7fbbcc4b66865dd1be2ebc98 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 15 Aug 2023 22:36:28 +0800 Subject: [PATCH 104/429] rename the loadOpenpypedata functions --- openpype/hosts/max/api/pipeline.py | 27 ++++++++++++++----- .../hosts/max/plugins/load/load_camera_fbx.py | 9 ++++--- openpype/hosts/max/plugins/load/load_model.py | 9 ++++--- .../hosts/max/plugins/load/load_model_fbx.py | 7 ++--- .../hosts/max/plugins/load/load_model_obj.py | 10 ++++--- .../hosts/max/plugins/load/load_model_usd.py | 8 +++--- .../hosts/max/plugins/load/load_pointcache.py | 9 ++++--- .../hosts/max/plugins/load/load_pointcloud.py | 8 +++--- .../max/plugins/load/load_redshift_proxy.py | 8 +++--- 9 files changed, 65 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 6b02f06b85..08ff5c6baf 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -174,16 +174,24 @@ def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): return container -def load_OpenpypeData(): - """Re-loading the Openpype parameter built by the creator +def load_custom_attribute_data(): + """Re-loading the Openpype/AYON custom parameter built by the creator + Returns: attribute: re-loading the custom OP attributes set in Maxscript """ return rt.Execute(MS_CUSTOM_ATTRIB) -def import_OpenpypeData(container, selections): - attrs = load_OpenpypeData() +def import_custom_attribute_data(container: str, selections: list): + """Importing the Openpype/AYON custom parameter built by the creator + + Args: + container (str): target container which adds custom attributes + selections (_type_): nodes to be added into + group in custom attributes + """ + attrs = load_custom_attribute_data() modifier = rt.EmptyModifier() rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" @@ -203,7 +211,14 @@ def import_OpenpypeData(container, selections): "sel_list", sel_list) -def update_Openpype_Data(container, selections): +def update_custom_attribute_data(container: str, selections: list): + """Updating the Openpype/AYON custom parameter built by the creator + + Args: + container (str): target container which adds custom attributes + selections (_type_): nodes to be added into + group in custom attributes + """ if container.modifiers[0].name == "OP Data": rt.deleteModifier(container, container.modifiers[0]) - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 7bd02e4615..1e4e5b3e91 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -32,7 +34,7 @@ class FbxLoader(load.LoaderPlugin): container = rt.container(name=name) selections = rt.GetCurrentSelection() - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) for selection in selections: selection.Parent = container @@ -47,7 +49,8 @@ class FbxLoader(load.LoaderPlugin): inst_name, _ = os.path.split(container["instance_node"]) container = rt.getNodeByName(inst_name) rt.Select(node.Children) - update_Openpype_Data(container, rt.GetCurrentSelection()) + update_custom_attribute_data( + container, rt.GetCurrentSelection()) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("Mode", rt.Name("merge")) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index e1978e35ad..f71e4e8f7f 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,7 +1,9 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -47,7 +49,8 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - import_OpenpypeData(abc_container, abc_container.Children) + import_custom_attribute_data( + abc_container, abc_container.Children) return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -64,7 +67,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_Openpype_Data(abc, abc.Children) + update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 9f80875d5b..26520307c9 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, import_custom_attribute_data, update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -32,7 +32,7 @@ class FbxModelLoader(load.LoaderPlugin): container.name = name selections = rt.GetCurrentSelection() - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) for selection in selections: selection.Parent = container @@ -57,7 +57,8 @@ class FbxModelLoader(load.LoaderPlugin): rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) container = rt.getNodeByName(inst_name) - update_Openpype_Data(container, rt.GetCurrentSelection()) + update_custom_attribute_data( + container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index f4791bfbb3..05f37f9e5a 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,11 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise, import_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, + import_custom_attribute_data, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -26,7 +30,7 @@ class ObjLoader(load.LoaderPlugin): container = rt.Container() container.name = name selections = rt.GetCurrentSelection() - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) # get current selection for selection in selections: selection.Parent = container @@ -53,7 +57,7 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.Parent = container - import_OpenpypeData(container, selections) + update_custom_attribute_data(container, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 96b5cdedf0..425b152278 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -3,7 +3,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -33,7 +35,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile(filepath, importOptions=import_options) asset = rt.GetNodeByName(name) - import_OpenpypeData(asset, asset.Children) + import_custom_attribute_data(asset, asset.Children) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -62,7 +64,7 @@ class ModelUSDLoader(load.LoaderPlugin): asset = rt.GetNodeByName(instance_name) asset.Parent = node - update_Openpype_Data(asset, asset.Children) + update_custom_attribute_data(asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 18a68732e9..0ec9fda3d5 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -8,7 +8,9 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) @@ -51,7 +53,8 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - import_OpenpypeData(abc_container, abc_container.Children) + import_custom_attribute_data( + abc_container, abc_container.Children) for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -77,7 +80,7 @@ class AbcLoader(load.LoaderPlugin): for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_Openpype_Data(abc, abc.Children) + update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2f41173bce..c263019beb 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -27,7 +29,7 @@ class PointCloudLoader(load.LoaderPlugin): prt_container = rt.container() prt_container.name = name obj.Parent = prt_container - import_OpenpypeData(prt_container, [obj]) + import_custom_attribute_data(prt_container, [obj]) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -43,7 +45,7 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - update_Openpype_Data(node, node.Children) + update_custom_attribute_data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 4b488bcb7c..6b100df611 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -6,7 +6,9 @@ from openpype.pipeline import ( get_representation_path ) from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.hosts.max.api import lib @@ -35,7 +37,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - import_OpenpypeData(container, [rs_proxy]) + import_custom_attribute_data(container, [rs_proxy]) asset = rt.getNodeByName(name) return containerise( @@ -51,7 +53,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path - update_Openpype_Data(node, node.Children) + update_custom_attribute_data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 8b0ba25c37d177b7b6a43f3536d3f98e9eb67898 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 14:12:58 +0800 Subject: [PATCH 105/429] add load maxscene family --- .../hosts/max/plugins/load/load_max_scene.py | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..637659ed44 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,10 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, import_custom_attribute_data, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -19,36 +22,43 @@ class MaxSceneLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - path = self.filepath_from_context(context) path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path) + rt.MergeMaxFile(path, quiet=True) max_objects = rt.getLastMergedNodes() - max_container = rt.Container(name=f"{name}") - for max_object in max_objects: - max_object.Parent = max_container - + # implement the OP/AYON custom attributes before load + max_container = [] + container = rt.Container(name=name) + import_custom_attribute_data(container, max_objects) + max_container.append(container) + max_container.extend(max_objects) return containerise( - name, [max_container], context, loader=self.__class__.__name__) + name, max_container, context, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] - - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) - + node = rt.GetNodeByName(node_name) + inst_name, _ = os.path.splitext(node_name) + old_container = rt.getNodeByName(inst_name) + # delete the old container with attribute + # delete old duplicate + rt.Delete(old_container) + rt.MergeMaxFile(path, rt.Name("deleteOldDups")) + new_container = rt.Container(name=inst_name) max_objects = rt.getLastMergedNodes() - container_node = rt.GetNodeByName(node_name) - for max_object in max_objects: - max_object.Parent = container_node + max_objects_list = [] + max_objects_list.append(new_container) + max_objects_list.extend(max_objects) + + for max_object in max_objects_list: + max_object.Parent = node + update_custom_attribute_data(new_container, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 0825afa73a0f4a706d3b091cfb068565381d5a40 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 14:22:14 +0800 Subject: [PATCH 106/429] add includedfullgroup support for merging scene in max scene family --- openpype/hosts/max/plugins/load/load_max_scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 637659ed44..7bbc6419b8 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -26,7 +26,7 @@ class MaxSceneLoader(load.LoaderPlugin): path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path, quiet=True) + rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() # implement the OP/AYON custom attributes before load max_container = [] From 86f86db4f84db54eecd127cbac528f3f9752107e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 17:55:07 +0800 Subject: [PATCH 107/429] also resolves OP-6526_3dsMax-loading-an-asset-multiple-times --- openpype/hosts/max/api/pipeline.py | 4 ++-- openpype/hosts/max/plugins/load/load_max_scene.py | 6 +++++- openpype/hosts/max/plugins/load/load_model_fbx.py | 6 +----- openpype/hosts/max/plugins/load/load_model_obj.py | 8 +++----- openpype/hosts/max/plugins/load/load_model_usd.py | 1 + 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 08ff5c6baf..f58bd05a13 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -188,7 +188,7 @@ def import_custom_attribute_data(container: str, selections: list): Args: container (str): target container which adds custom attributes - selections (_type_): nodes to be added into + selections (list): nodes to be added into group in custom attributes """ attrs = load_custom_attribute_data() @@ -216,7 +216,7 @@ def update_custom_attribute_data(container: str, selections: list): Args: container (str): target container which adds custom attributes - selections (_type_): nodes to be added into + selections (list): nodes to be added into group in custom attributes """ if container.modifiers[0].name == "OP Data": diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 7bbc6419b8..2f5108aec5 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -51,7 +51,11 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path, rt.Name("deleteOldDups")) new_container = rt.Container(name=inst_name) max_objects = rt.getLastMergedNodes() - + current_max_objects = rt.getLastMergedNodes() + for current_object in current_max_objects: + prev_max_objects = prev_max_objects.remove(current_object) + for prev_object in prev_max_objects: + rt.Delete(prev_object) max_objects_list = [] max_objects_list.append(new_container) max_objects_list.extend(max_objects) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 26520307c9..d076bf2de9 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -26,11 +26,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(name) - if not container: - container = rt.Container() - container.name = name - + container = rt.Container(name=name) selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 05f37f9e5a..bac5b8b4f3 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -27,18 +27,16 @@ class ObjLoader(load.LoaderPlugin): rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') # create "missing" container for obj import - container = rt.Container() - container.name = name + container = rt.Container(name=name) selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) # get current selection for selection in selections: selection.Parent = container - - asset = rt.GetNodeByName(name) + self.log.debug(f"{container.ClassID}") return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [container], context, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 425b152278..d3669fc10e 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -35,6 +35,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile(filepath, importOptions=import_options) asset = rt.GetNodeByName(name) + import_custom_attribute_data(asset, asset.Children) return containerise( From ae42d524c80bab1d5f3583111eb5208c9d515caf Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 18:22:59 +0800 Subject: [PATCH 108/429] fixing the error when updating the max scene in the loader --- openpype/hosts/max/plugins/load/load_max_scene.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 2f5108aec5..f73bb1941e 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -45,12 +45,12 @@ class MaxSceneLoader(load.LoaderPlugin): node = rt.GetNodeByName(node_name) inst_name, _ = os.path.splitext(node_name) old_container = rt.getNodeByName(inst_name) + prev_max_objects = rt.getLastMergedNodes() # delete the old container with attribute # delete old duplicate rt.Delete(old_container) rt.MergeMaxFile(path, rt.Name("deleteOldDups")) new_container = rt.Container(name=inst_name) - max_objects = rt.getLastMergedNodes() current_max_objects = rt.getLastMergedNodes() for current_object in current_max_objects: prev_max_objects = prev_max_objects.remove(current_object) @@ -58,11 +58,11 @@ class MaxSceneLoader(load.LoaderPlugin): rt.Delete(prev_object) max_objects_list = [] max_objects_list.append(new_container) - max_objects_list.extend(max_objects) + max_objects_list.extend(current_max_objects) for max_object in max_objects_list: max_object.Parent = node - update_custom_attribute_data(new_container, max_objects) + update_custom_attribute_data(new_container, current_max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 8aa150cfe5af1c7259b5f4466835638249c29b73 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 21:37:49 +0800 Subject: [PATCH 109/429] fixing the bug of not being able to update the scene when using maxSceneloader and some clean up --- .../hosts/max/plugins/load/load_camera_fbx.py | 13 ++++---- .../hosts/max/plugins/load/load_max_scene.py | 33 ++++++++++--------- openpype/hosts/max/plugins/load/load_model.py | 6 ++-- .../hosts/max/plugins/load/load_model_fbx.py | 14 +++++--- .../hosts/max/plugins/load/load_model_obj.py | 7 ++-- .../hosts/max/plugins/load/load_pointcache.py | 14 ++++---- 6 files changed, 46 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 1e4e5b3e91..87745ae881 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -45,12 +45,11 @@ class FbxLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.GetNodeByName(container["instance_node"]) - inst_name, _ = os.path.split(container["instance_node"]) - container = rt.getNodeByName(inst_name) + node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + inst_name, _ = node_name.split("_") rt.Select(node.Children) - update_custom_attribute_data( - container, rt.GetCurrentSelection()) + rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("Mode", rt.Name("merge")) @@ -58,7 +57,9 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - + inst_container = rt.getNodeByName(inst_name) + update_custom_attribute_data( + inst_container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index f73bb1941e..348b940b22 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -42,27 +42,28 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.GetNodeByName(node_name) - inst_name, _ = os.path.splitext(node_name) - old_container = rt.getNodeByName(inst_name) - prev_max_objects = rt.getLastMergedNodes() + node = rt.getNodeByName(node_name) + inst_name, _ = node_name.split("_") + inst_container = rt.getNodeByName(inst_name) # delete the old container with attribute # delete old duplicate - rt.Delete(old_container) + prev_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] rt.MergeMaxFile(path, rt.Name("deleteOldDups")) - new_container = rt.Container(name=inst_name) - current_max_objects = rt.getLastMergedNodes() - for current_object in current_max_objects: - prev_max_objects = prev_max_objects.remove(current_object) - for prev_object in prev_max_objects: - rt.Delete(prev_object) - max_objects_list = [] - max_objects_list.append(new_container) - max_objects_list.extend(current_max_objects) - for max_object in max_objects_list: + current_max_objects = rt.getLastMergedNodes() + current_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] + for obj in current_max_object_names: + idx = rt.findItem(prev_max_object_names, obj) + if idx: + prev_max_object_names = rt.deleteItem(prev_max_object_names, idx) + for object_name in prev_max_object_names: + prev_max_object = rt.getNodeByName(object_name) + rt.Delete(prev_max_object) + + update_custom_attribute_data(inst_container, current_max_objects) + + for max_object in current_max_objects: max_object.Parent = node - update_custom_attribute_data(new_container, current_max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index f71e4e8f7f..a84d497aab 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -70,9 +70,9 @@ class ModelAbcLoader(load.LoaderPlugin): update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: - container = rt.GetNodeByName(abc_con.name) - container.source = path - rt.Select(container.Children) + abc_container = rt.GetNodeByName(abc_con.name) + abc_container.source = path + rt.Select(abc_container.Children) for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index d076bf2de9..f7d3dab60c 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -26,7 +26,10 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) + container = rt.GetNodeByName(name) + container = rt.Container(name=name) + selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) @@ -40,8 +43,9 @@ class FbxModelLoader(load.LoaderPlugin): def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - inst_name, _ = os.path.splitext(container["instance_node"]) + node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + inst_name, _ = node_name.split("_") rt.select(node.Children) rt.FBXImporterSetParam("Animation", False) @@ -52,14 +56,14 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.getNodeByName(inst_name) + inst_container = rt.getNodeByName(inst_name) update_custom_attribute_data( - container, rt.GetCurrentSelection()) + inst_container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) lib.imprint( - container["instance_node"], + node_name, {"representation": str(representation["_id"])}, ) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index bac5b8b4f3..9979ca36b0 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -33,7 +33,6 @@ class ObjLoader(load.LoaderPlugin): # get current selection for selection in selections: selection.Parent = container - self.log.debug(f"{container.ClassID}") return containerise( name, [container], context, loader=self.__class__.__name__) @@ -46,7 +45,7 @@ class ObjLoader(load.LoaderPlugin): node = rt.GetNodeByName(node_name) instance_name, _ = node_name.split("_") - container = rt.GetNodeByName(instance_name) + inst_container = rt.GetNodeByName(instance_name) for child in container.Children: rt.Delete(child) @@ -54,8 +53,8 @@ class ObjLoader(load.LoaderPlugin): # get current selection selections = rt.GetCurrentSelection() for selection in selections: - selection.Parent = container - update_custom_attribute_data(container, selections) + selection.Parent = inst_container + update_custom_attribute_data(inst_container, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 0ec9fda3d5..953141c4ac 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -70,10 +70,6 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) nodes_list = [] with maintained_selection(): rt.Select(node.Children) @@ -83,14 +79,18 @@ class AbcLoader(load.LoaderPlugin): update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: - container = rt.GetNodeByName(abc_con.name) - container.source = path - rt.Select(container.Children) + abc_container = rt.GetNodeByName(abc_con.name) + abc_container.source = path + rt.Select(abc_container.Children) for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) def switch(self, container, representation): self.update(container, representation) From 3342ceff2cee9a44c34c265cb51c7e2e8bcfa799 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 22:39:36 +0800 Subject: [PATCH 110/429] clean up on the fbx and max_scene code --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 4 ++++ openpype/hosts/max/plugins/load/load_max_scene.py | 10 ++++++---- openpype/hosts/max/plugins/load/load_model_fbx.py | 11 ++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 87745ae881..86e201afa8 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -57,7 +57,11 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) + current_fbx_objects = rt.GetCurrentSelection() inst_container = rt.getNodeByName(inst_name) + for fbx_object in current_fbx_objects: + if fbx_object.Parent != inst_container: + fbx_object.Parent = inst_container update_custom_attribute_data( inst_container, rt.GetCurrentSelection()) with maintained_selection(): diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 348b940b22..4f29f6bd3a 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -47,13 +47,15 @@ class MaxSceneLoader(load.LoaderPlugin): inst_container = rt.getNodeByName(inst_name) # delete the old container with attribute # delete old duplicate - prev_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] + prev_max_object_names = [obj.name for obj + in rt.getLastMergedNodes()] rt.MergeMaxFile(path, rt.Name("deleteOldDups")) current_max_objects = rt.getLastMergedNodes() - current_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] - for obj in current_max_object_names: - idx = rt.findItem(prev_max_object_names, obj) + current_max_object_names = [obj.name for obj + in current_max_objects] + for name in current_max_object_names: + idx = rt.findItem(prev_max_object_names, name) if idx: prev_max_object_names = rt.deleteItem(prev_max_object_names, idx) for object_name in prev_max_object_names: diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index f7d3dab60c..67252a73ff 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -46,19 +46,20 @@ class FbxModelLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) inst_name, _ = node_name.split("_") - rt.select(node.Children) + inst_container = rt.getNodeByName(inst_name) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) - rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - - inst_container = rt.getNodeByName(inst_name) + current_fbx_objects = rt.GetCurrentSelection() + for fbx_object in current_fbx_objects: + if fbx_object.Parent != inst_container: + fbx_object.Parent = inst_container update_custom_attribute_data( - inst_container, rt.GetCurrentSelection()) + inst_container, current_fbx_objects) with maintained_selection(): rt.Select(node) From 8345298913cf88205b1217c261ad3c0dcdf6a946 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 22:40:56 +0800 Subject: [PATCH 111/429] hound --- openpype/hosts/max/plugins/load/load_max_scene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 4f29f6bd3a..9c7468b8fc 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -57,7 +57,8 @@ class MaxSceneLoader(load.LoaderPlugin): for name in current_max_object_names: idx = rt.findItem(prev_max_object_names, name) if idx: - prev_max_object_names = rt.deleteItem(prev_max_object_names, idx) + prev_max_object_names = rt.deleteItem( + prev_max_object_names, idx) for object_name in prev_max_object_names: prev_max_object = rt.getNodeByName(object_name) rt.Delete(prev_max_object) From b2a6e16ae8a1466843fdd4958a7b49bb14adc34a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 17 Aug 2023 21:22:34 +0800 Subject: [PATCH 112/429] master container is now with the namespace --- openpype/hosts/max/api/lib.py | 58 ++++++++++++++++++- openpype/hosts/max/api/pipeline.py | 7 ++- .../hosts/max/plugins/load/load_camera_fbx.py | 9 ++- .../hosts/max/plugins/load/load_max_scene.py | 10 +++- openpype/hosts/max/plugins/load/load_model.py | 13 ++++- .../hosts/max/plugins/load/load_model_fbx.py | 8 ++- .../hosts/max/plugins/load/load_model_obj.py | 8 ++- .../hosts/max/plugins/load/load_model_usd.py | 9 ++- .../hosts/max/plugins/load/load_pointcache.py | 8 ++- .../hosts/max/plugins/load/load_pointcloud.py | 9 ++- .../max/plugins/load/load_redshift_proxy.py | 9 ++- 11 files changed, 134 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index ccd4cd67e1..b58b4f5b11 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Union import six from openpype.pipeline.context_tools import ( - get_current_project, get_current_project_asset,) + get_current_project, get_current_project_asset) from pymxs import runtime as rt JSON_PREFIX = "JSON::" @@ -312,3 +312,59 @@ def set_timeline(frameStart, frameEnd): """ rt.animationRange = rt.interval(frameStart, frameEnd) return rt.animationRange + + +def unique_namespace(namespace, format="%02d", + prefix="", suffix="", con_suffix="CON"): + from pymxs import runtime as rt + """Return unique namespace + + Arguments: + namespace (str): Name of namespace to consider + format (str, optional): Formatting of the given iteration number + suffix (str, optional): Only consider namespaces with this suffix. + con_suffix: max only, for finding the name of the master container + + >>> unique_namespace("bar") + # bar01 + >>> unique_namespace(":hello") + # :hello01 + >>> unique_namespace("bar:", suffix="_NS") + # bar01_NS: + + """ + + def current_namespace(): + current = namespace + # When inside a namespace Maya adds no trailing : + if not current.endswith(":"): + current += ":" + return current + + # Always check against the absolute namespace root + # There's no clash with :x if we're defining namespace :a:x + ROOT = ":" if namespace.startswith(":") else current_namespace() + + # Strip trailing `:` tokens since we might want to add a suffix + start = ":" if namespace.startswith(":") else "" + end = ":" if namespace.endswith(":") else "" + namespace = namespace.strip(":") + if ":" in namespace: + # Split off any nesting that we don't uniqify anyway. + parents, namespace = namespace.rsplit(":", 1) + start += parents + ":" + ROOT += start + + iteration = 1 + increment_version = True + while increment_version: + nr_namespace = namespace + format % iteration + unique = prefix + nr_namespace + suffix + container_name = f"{unique}:{namespace}{con_suffix}" + if not rt.getNodeByName(container_name): + name_space = start + unique + end + increment_version = False + return name_space + else: + increment_version = True + iteration +=1 diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index f58bd05a13..459c8b32f0 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -154,17 +154,18 @@ def ls() -> list: yield lib.read(container) -def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): +def containerise(name: str, nodes: list, context, + namespace=None, loader=None, suffix="_CON"): data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, - "namespace": "", + "namespace": namespace, "loader": loader, "representation": context["representation"]["_id"], } - container_name = f"{name}{suffix}" + container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) for node in nodes: node.Parent = container diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 86e201afa8..180c1b48b8 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -38,8 +39,14 @@ class FbxLoader(load.LoaderPlugin): for selection in selections: selection.Parent = container + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [container], context, loader=self.__class__.__name__) + name, [container], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 9c7468b8fc..7c00706676 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data @@ -34,8 +35,15 @@ class MaxSceneLoader(load.LoaderPlugin): import_custom_attribute_data(container, max_objects) max_container.append(container) max_container.extend(max_objects) + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, max_container, context, loader=self.__class__.__name__) + name, max_container, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index a84d497aab..ebf3d684c8 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -6,7 +6,9 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import maintained_selection +from openpype.hosts.max.api.lib import ( + maintained_selection, unique_namespace +) class ModelAbcLoader(load.LoaderPlugin): @@ -51,8 +53,15 @@ class ModelAbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() import_custom_attribute_data( abc_container, abc_container.Children) + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, [abc_container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 67252a73ff..34ac263821 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -4,6 +4,7 @@ from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data ) from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.lib import maintained_selection @@ -36,8 +37,13 @@ class FbxModelLoader(load.LoaderPlugin): for selection in selections: selection.Parent = container + namespace = unique_namespace( + name + "_", + suffix="_", + ) return containerise( - name, [container], context, loader=self.__class__.__name__ + name, [container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 9979ca36b0..e4ae687802 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, @@ -34,8 +35,13 @@ class ObjLoader(load.LoaderPlugin): for selection in selections: selection.Parent = container + namespace = unique_namespace( + name + "_", + suffix="_", + ) return containerise( - name, [container], context, loader=self.__class__.__name__) + name, [container], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index d3669fc10e..fa013f54ce 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, @@ -38,8 +39,14 @@ class ModelUSDLoader(load.LoaderPlugin): import_custom_attribute_data(asset, asset.Children) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [asset], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 953141c4ac..3dacab11c7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,6 +7,7 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -59,9 +60,14 @@ class AbcLoader(load.LoaderPlugin): for cam_shape in abc.Children: cam_shape.playbackType = 2 + namespace = unique_namespace( + name + "_", + suffix="_", + ) return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, [abc_container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index c263019beb..58d5057aa7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -31,8 +32,14 @@ class PointCloudLoader(load.LoaderPlugin): obj.Parent = prt_container import_custom_attribute_data(prt_container, [obj]) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [prt_container], context, loader=self.__class__.__name__) + name, [prt_container], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): """update the container""" diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 6b100df611..b4772ac0bc 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -11,6 +11,7 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace class RedshiftProxyLoader(load.LoaderPlugin): @@ -40,8 +41,14 @@ class RedshiftProxyLoader(load.LoaderPlugin): import_custom_attribute_data(container, [rs_proxy]) asset = rt.getNodeByName(name) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [asset], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt From 5a00cab24cd1f0dc4dd2988f28fa9b7b88b0b63b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 17 Aug 2023 21:24:10 +0800 Subject: [PATCH 113/429] hound --- openpype/hosts/max/api/lib.py | 2 +- openpype/hosts/max/api/pipeline.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index b58b4f5b11..e357080cbc 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -367,4 +367,4 @@ def unique_namespace(namespace, format="%02d", return name_space else: increment_version = True - iteration +=1 + iteration += 1 diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 459c8b32f0..161e2bdc7b 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -160,7 +160,7 @@ def containerise(name: str, nodes: list, context, "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, - "namespace": namespace, + "namespace": namespace or "", "loader": loader, "representation": context["representation"]["_id"], } From ab3d94fdb6f607b958704a06d3fa87187ac03d71 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Thu, 17 Aug 2023 15:59:46 -0700 Subject: [PATCH 114/429] Move variant query to the create_interactive function --- .../hosts/houdini/api/creator_node_shelves.py | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 01da2fc3e1..1f9fef7417 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -35,11 +35,11 @@ CATEGORY_GENERIC_TOOL = { CREATE_SCRIPT = """ from openpype.hosts.houdini.api.creator_node_shelves import create_interactive -create_interactive("{identifier}", "{variant}", **kwargs) +create_interactive("{identifier}", **kwargs) """ -def create_interactive(creator_identifier, default_variant, **kwargs): +def create_interactive(creator_identifier, **kwargs): """Create a Creator using its identifier interactively. This is used by the generated shelf tools as callback when a user selects @@ -57,28 +57,31 @@ def create_interactive(creator_identifier, default_variant, **kwargs): list: The created instances. """ - - # TODO Use Qt instead - result, variant = hou.ui.readInput("Define variant name", - buttons=("Ok", "Cancel"), - initial_contents=default_variant, - title="Define variant", - help="Set the variant for the " - "publish instance", - close_choice=1) - if result == 1: - # User interrupted - return - variant = variant.strip() - if not variant: - raise RuntimeError("Empty variant value entered.") - host = registered_host() context = CreateContext(host) creator = context.manual_creators.get(creator_identifier) if not creator: - raise RuntimeError("Invalid creator identifier: " - "{}".format(creator_identifier)) + raise RuntimeError("Invalid creator identifier: {}".format( + creator_identifier) + ) + + # TODO Use Qt instead + result, variant = hou.ui.readInput( + "Define variant name", + buttons=("Ok", "Cancel"), + initial_contents=creator.get_default_variant(), + title="Define variant", + help="Set the variant for the publish instance", + close_choice=1 + ) + + if result == 1: + # User interrupted + return + + variant = variant.strip() + if not variant: + raise RuntimeError("Empty variant value entered.") # TODO: Once more elaborate unique create behavior should exist per Creator # instead of per network editor area then we should move this from here @@ -196,9 +199,7 @@ def install(): key = "openpype_create.{}".format(identifier) log.debug(f"Registering {key}") - script = CREATE_SCRIPT.format( - identifier=identifier, variant=creator.get_default_variant() - ) + script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, From f4e42e27ac01faa61c71b2dec930a78361645578 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 18 Aug 2023 18:43:13 +0800 Subject: [PATCH 115/429] updating version should be updated as expected --- .../hosts/max/plugins/load/load_camera_fbx.py | 15 ++++++++--- .../hosts/max/plugins/load/load_max_scene.py | 15 +++++++---- openpype/hosts/max/plugins/load/load_model.py | 26 ++++++++----------- .../hosts/max/plugins/load/load_model_fbx.py | 17 +++++++----- .../hosts/max/plugins/load/load_model_obj.py | 8 +++--- .../hosts/max/plugins/load/load_model_usd.py | 11 +++++--- .../hosts/max/plugins/load/load_pointcache.py | 14 ++++------ .../hosts/max/plugins/load/load_pointcloud.py | 14 +++++----- .../max/plugins/load/load_redshift_proxy.py | 15 +++++------ 9 files changed, 75 insertions(+), 60 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 180c1b48b8..c0e1172a6d 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -54,7 +54,10 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - inst_name, _ = node_name.split("_") + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") + + inst_container = rt.getNodeByName(param_container) rt.Select(node.Children) rt.FBXImporterSetParam("Animation", True) @@ -65,12 +68,16 @@ class FbxLoader(load.LoaderPlugin): rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() - inst_container = rt.getNodeByName(inst_name) for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container - update_custom_attribute_data( - inst_container, rt.GetCurrentSelection()) + + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_fbx_objects) + with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 7c00706676..aa177291d8 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -40,7 +40,6 @@ class MaxSceneLoader(load.LoaderPlugin): name + "_", suffix="_", ) - return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -50,9 +49,11 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] + node = rt.getNodeByName(node_name) - inst_name, _ = node_name.split("_") - inst_container = rt.getNodeByName(inst_name) + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") + # delete the old container with attribute # delete old duplicate prev_max_object_names = [obj.name for obj @@ -71,10 +72,14 @@ class MaxSceneLoader(load.LoaderPlugin): prev_max_object = rt.getNodeByName(object_name) rt.Delete(prev_max_object) - update_custom_attribute_data(inst_container, current_max_objects) - for max_object in current_max_objects: max_object.Parent = node + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_max_objects) + lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index ebf3d684c8..deb3389992 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -69,23 +69,19 @@ class ModelAbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - rt.Select(node.Children) - nodes_list = [] with maintained_selection(): - rt.Select(node) - for alembic in rt.Selection: - abc = rt.GetNodeByName(alembic.name) - update_custom_attribute_data(abc, abc.Children) - rt.Select(abc.Children) - for abc_con in rt.Selection: - abc_container = rt.GetNodeByName(abc_con.name) - abc_container.source = path - rt.Select(abc_container.Children) - for abc_obj in rt.Selection: - alembic_obj = rt.GetNodeByName(abc_obj.name) - alembic_obj.source = path - nodes_list.append(alembic_obj) + rt.Select(node.Children) + + for alembic in rt.Selection: + abc = rt.GetNodeByName(alembic.name) + update_custom_attribute_data(abc, abc.Children) + rt.Select(abc.Children) + for abc_con in abc.Children: + abc_con.source = path + rt.Select(abc_con.Children) + for abc_obj in abc_con.Children: + abc_obj.source = path lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 34ac263821..f85bfa03a1 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,8 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_custom_attribute_data, update_custom_attribute_data + containerise, import_custom_attribute_data, + update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import unique_namespace @@ -51,9 +52,8 @@ class FbxModelLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - inst_name, _ = node_name.split("_") - inst_container = rt.getNodeByName(inst_name) - + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("merge")) @@ -61,11 +61,16 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() + + inst_container = rt.getNodeByName(param_container) + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_fbx_objects) for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container - update_custom_attribute_data( - inst_container, current_fbx_objects) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index e4ae687802..b42ef399b0 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -50,9 +50,11 @@ class ObjLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.GetNodeByName(node_name) - instance_name, _ = node_name.split("_") - inst_container = rt.GetNodeByName(instance_name) - for child in container.Children: + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") + + inst_container = rt.getNodeByName(param_container) + for child in inst_container.Children: rt.Delete(child) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index fa013f54ce..4febba216e 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -58,7 +58,8 @@ class ModelUSDLoader(load.LoaderPlugin): for r in n.Children: rt.Delete(r) rt.Delete(n) - instance_name, _ = node_name.split("_") + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(path) @@ -70,9 +71,13 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile( path, importOptions=import_options) - asset = rt.GetNodeByName(instance_name) + asset = rt.GetNodeByName(param_container) asset.Parent = node - update_custom_attribute_data(asset, asset.Children) + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 3dacab11c7..af03e70236 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -76,7 +76,6 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - nodes_list = [] with maintained_selection(): rt.Select(node.Children) @@ -84,14 +83,11 @@ class AbcLoader(load.LoaderPlugin): abc = rt.GetNodeByName(alembic.name) update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) - for abc_con in rt.Selection: - abc_container = rt.GetNodeByName(abc_con.name) - abc_container.source = path - rt.Select(abc_container.Children) - for abc_obj in rt.Selection: - alembic_obj = rt.GetNodeByName(abc_obj.name) - alembic_obj.source = path - nodes_list.append(alembic_obj) + for abc_con in abc.Children: + abc_con.source = path + rt.Select(abc_con.Children) + for abc_obj in abc_con.Children: + abc_obj.source = path lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 58d5057aa7..6c94fb7847 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -26,9 +26,7 @@ class PointCloudLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.GetNodeByName(obj.name) - prt_container = rt.container() - prt_container.name = name + prt_container = rt.Container(name=name) obj.Parent = prt_container import_custom_attribute_data(prt_container, [obj]) @@ -49,10 +47,12 @@ class PointCloudLoader(load.LoaderPlugin): node = rt.GetNodeByName(container["instance_node"]) with maintained_selection(): rt.Select(node.Children) - for prt in rt.Selection: - prt_object = rt.GetNodeByName(prt.name) - prt_object.filename = path - update_custom_attribute_data(node, node.Children) + for sub_node in rt.Selection: + children_node = sub_node.Children + update_custom_attribute_data( + sub_node, sub_node.Children) + for prt in children_node: + prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index b4772ac0bc..1c4cd02143 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -35,11 +35,9 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - container = rt.container() - container.name = name + container = rt.Container(name=name) rs_proxy.Parent = container import_custom_attribute_data(container, [rs_proxy]) - asset = rt.getNodeByName(name) namespace = unique_namespace( name + "_", @@ -47,7 +45,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): ) return containerise( - name, [asset], context, + name, [container], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -55,12 +53,13 @@ class RedshiftProxyLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) - for children in node.Children: - children_node = rt.getNodeByName(children.name) - for proxy in children_node.Children: + for sub_node in node.Children: + children_node = sub_node.Children + update_custom_attribute_data( + sub_node, children_node) + for proxy in children_node: proxy.file = path - update_custom_attribute_data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 444cca4213128d470641a58ffac4602b9ac5f833 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 21 Aug 2023 15:30:46 +0800 Subject: [PATCH 116/429] load max scene wip --- .../hosts/max/plugins/load/load_max_scene.py | 66 +++++++++++++------ 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 1d105f1bc0..6dfb3d0115 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,11 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.pipeline import ( + containerise, import_custom_attribute_data, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -19,37 +23,61 @@ class MaxSceneLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - path = self.filepath_from_context(context) path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile( - path, rt.Name("mergeDups"), - includeFullGroup=True) + rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() + # implement the OP/AYON custom attributes before load + max_container = [] + namespace = unique_namespace( + name + "_", + suffix="_", + ) + container = rt.Container(name=f"{namespace}:{name}") + import_custom_attribute_data(container, max_objects) + max_container.append(container) + max_container.extend(max_objects) return containerise( - name, [max_objects], context, loader=self.__class__.__name__) + name, max_container, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - prev_max_objects = rt.getLastMergedNodes() - merged_max_objects = [obj.name for obj - in prev_max_objects] - rt.MergeMaxFile( - path, merged_max_objects, - rt.Name("deleteOldDups"), - quiet=True, - mergedNodes=prev_max_objects, - includeFullGroup=True) + node_name = container["instance_node"] + + node = rt.getNodeByName(node_name) + param_container = node_name.split("_CON")[0] + + # delete the old container with attribute + # delete old duplicate + prev_max_object_names = [obj.name for obj + in rt.getLastMergedNodes()] + rt.MergeMaxFile(path, rt.Name("deleteOldDups")) + current_max_objects = rt.getLastMergedNodes() - for current_object in current_max_objects: - prev_max_objects = prev_max_objects.remove(current_object) - for prev_object in prev_max_objects: - rt.Delete(prev_object) + current_max_object_names = [obj.name for obj + in current_max_objects] + for name in current_max_object_names: + idx = rt.findItem(prev_max_object_names, name) + if idx: + prev_max_object_names = rt.deleteItem( + prev_max_object_names, idx) + for object_name in prev_max_object_names: + prev_max_object = rt.getNodeByName(object_name) + rt.Delete(prev_max_object) + + for max_object in current_max_objects: + max_object.Parent = node + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) From e742dd61fd85c7cac3d5027ae758bef2f7aa5af7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Aug 2023 14:42:02 +0200 Subject: [PATCH 117/429] fill entities and update template data on instances during extract AYON hierarchy --- .../publish/extract_hierarchy_to_ayon.py | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index 915650ae41..de9a70c233 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -8,6 +8,11 @@ from ayon_api import slugify_string from ayon_api.entity_hub import EntityHub from openpype import AYON_SERVER_ENABLED +from openpype.client import get_assets +from openpype.pipeline.template_data import ( + get_asset_template_data, + get_task_template_data, +) def _default_json_parse(value): @@ -27,13 +32,48 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): hierarchy_context = context.data.get("hierarchyContext") if not hierarchy_context: - self.log.info("Skipping") + self.log.debug("Skipping") return project_name = context.data["projectName"] + self._create_hierarchy(context, project_name) + self._fill_instance_entities(context, project_name) + + def _fill_instance_entities(self, context, project_name): + instances_by_asset_name = collections.defaultdict(list) + for instance in context: + instance_entity = instance.data.get("assetEntity") + if instance_entity: + continue + + # Skip if instance asset does not match + instance_asset_name = instance.data.get("asset") + instances_by_asset_name[instance_asset_name] = instance + + project_doc = context.data["projectEntity"] + asset_docs = get_assets( + project_name, asset_names=instances_by_asset_name.keys() + ) + asset_docs_by_name = { + asset_doc["name"]: asset_doc + for asset_doc in asset_docs + } + for asset_name, instances in instances_by_asset_name.items(): + asset_doc = asset_docs_by_name[asset_name] + asset_data = get_asset_template_data(asset_doc, project_name) + for instance in instances: + task_name = instance.data.get("task") + template_data = get_task_template_data( + project_doc, asset_doc, task_name) + template_data.update(copy.deepcopy(asset_data)) + + instance.data["anatomyData"].update(template_data) + instance.data["assetEntity"] = asset_doc + + def _create_hierarchy(self, context, project_name): hierarchy_context = self._filter_hierarchy(context) if not hierarchy_context: - self.log.info("All folders were filtered out") + self.log.debug("All folders were filtered out") return self.log.debug("Hierarchy_context: {}".format( From 32b93f998aa95c3647797040b4a73fd1e68a305f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 22 Aug 2023 12:47:36 +0800 Subject: [PATCH 118/429] allows the users to choose which camera as repair action in validate_viewport_camera --- openpype/hosts/max/api/lib_rendersettings.py | 5 +++-- .../publish/validate_viewport_camera.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py index 1b62edabee..afde5008d5 100644 --- a/openpype/hosts/max/api/lib_rendersettings.py +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -43,7 +43,7 @@ class RenderSettings(object): rt.viewport.setCamera(sel) break if not found: - raise RuntimeError("Camera not found") + raise RuntimeError("Active Camera not found") def render_output(self, container): folder = rt.maxFilePath @@ -113,7 +113,8 @@ class RenderSettings(object): # for setting up renderable camera arv = rt.MAXToAOps.ArnoldRenderView() render_camera = rt.viewport.GetCamera() - arv.setOption("Camera", str(render_camera)) + if render_camera: + arv.setOption("Camera", str(render_camera)) # TODO: add AOVs and extension img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py index d5cf85eb69..b35ba482a9 100644 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -4,6 +4,7 @@ from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin) from openpype.pipeline.publish import RepairAction +from openpype.hosts.max.api.lib import get_current_renderer from pymxs import runtime as rt @@ -35,11 +36,13 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): - # Get all cameras in the scene - cameras_in_scene = [c.name for c in rt.Objects - if rt.classOf(c) in rt.Camera.Classes] - # Set the first camera as viewport camera for rendering - if cameras_in_scene: - camera = rt.getNodeByName(cameras_in_scene[0]) - rt.viewport.setCamera(camera) - cls.log.info(f"Camera {camera} set as viewport camera") + + rt.viewport.setType(rt.Name("view_camera")) + camera = rt.viewport.GetCamera() + cls.log.info(f"Camera {camera} set as viewport camera") + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + if renderer == "Arnold": + arv = rt.MAXToAOps.ArnoldRenderView() + arv.setOption("Camera", str(camera)) + arv.close() From dfd748ccd191e3d6058529d8deaf2865c7a9ec89 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 22 Aug 2023 08:27:52 +0100 Subject: [PATCH 119/429] Remove force copy option --- openpype/hosts/maya/plugins/create/create_look.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_look.py b/openpype/hosts/maya/plugins/create/create_look.py index 385ae81e01..11a69151fd 100644 --- a/openpype/hosts/maya/plugins/create/create_look.py +++ b/openpype/hosts/maya/plugins/create/create_look.py @@ -37,13 +37,7 @@ class CreateLook(plugin.MayaCreator): label="Convert textures to .rstex", tooltip="Whether to generate Redshift .rstex files for " "your textures", - default=self.rs_tex), - BoolDef("forceCopy", - label="Force Copy", - tooltip="Enable users to force a copy instead of hardlink." - "\nNote: On Windows copy is always forced due to " - "bugs in windows' implementation of hardlinks.", - default=False) + default=self.rs_tex) ] def get_pre_create_attr_defs(self): From ef58284bceb6bff2a6ee965bc456600d821e80a8 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 22 Aug 2023 11:30:45 +0100 Subject: [PATCH 120/429] Respect persistent dir on Deadline. --- openpype/plugins/publish/collect_rendered_files.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 4b95d8ac44..dc54e296e1 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -103,13 +103,16 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # stash render job id for later validation instance.data["render_job_id"] = data.get("job").get("_id") - + staging_dir_persistent = instance.data.get( + "stagingDir_persistent", False + ) representations = [] for repre_data in instance_data.get("representations") or []: self._fill_staging_dir(repre_data, anatomy) representations.append(repre_data) - add_repre_files_for_cleanup(instance, repre_data) + if not staging_dir_persistent: + add_repre_files_for_cleanup(instance, repre_data) instance.data["representations"] = representations @@ -124,7 +127,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.info( f"Adding audio to instance: {instance.data['audio']}") - return instance.data.get("stagingDir_persistent", False) + return staging_dir_persistent def process(self, context): self._context = context From 6376692fec24267af13f7900e2ac60fd65aed373 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 13:56:47 +0200 Subject: [PATCH 121/429] :recycle: use temp dir for project creation --- .../unreal/hooks/pre_workfile_preparation.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 202d7854f6..fad7a7ed0b 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -2,6 +2,7 @@ """Hook to launch Unreal and prepare projects.""" import os import copy +import tempfile from pathlib import Path from qtpy import QtCore @@ -224,10 +225,19 @@ class UnrealPrelaunchHook(PreLaunchHook): project_file = project_path / unreal_project_filename if not project_file.is_file(): - self.exec_ue_project_gen(engine_version, - unreal_project_name, - engine_path, - project_path) + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) / unreal_project_filename + self.exec_ue_project_gen(engine_version, + unreal_project_name, + engine_path, + temp_path) + try: + temp_path.rename(project_path) + except FileExistsError as e: + raise ApplicationLaunchFailed(( + f"{self.signature} Project folder " + f"already exists {project_path.as_posix()}" + )) from e self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version # Append project file to launch arguments From 92165f521ef5f51e46758b3068083339f8d7a14d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 15:41:24 +0200 Subject: [PATCH 122/429] :bug: fix copy function --- .../unreal/hooks/pre_workfile_preparation.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index fad7a7ed0b..fb489f04f7 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -2,6 +2,7 @@ """Hook to launch Unreal and prepare projects.""" import os import copy +import shutil import tempfile from pathlib import Path @@ -230,13 +231,19 @@ class UnrealPrelaunchHook(PreLaunchHook): self.exec_ue_project_gen(engine_version, unreal_project_name, engine_path, - temp_path) + Path(temp_dir)) try: - temp_path.rename(project_path) - except FileExistsError as e: + self.log.info(( + f"Moving from {temp_dir} to " + f"{project_path.as_posix()}" + )) + shutil.copytree( + temp_dir, project_path, dirs_exist_ok=True) + + except shutil.Error as e: raise ApplicationLaunchFailed(( - f"{self.signature} Project folder " - f"already exists {project_path.as_posix()}" + f"{self.signature} Cannot copy directory {temp_dir} " + f"to {project_path.as_posix()} - {e}" )) from e self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version From c2b49a897eee6c33e405a3229bd48bcaee6c8178 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 15:43:48 +0200 Subject: [PATCH 123/429] :recycle: remove unused variable --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index fb489f04f7..efbbed27c8 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -227,7 +227,6 @@ class UnrealPrelaunchHook(PreLaunchHook): if not project_file.is_file(): with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) / unreal_project_filename self.exec_ue_project_gen(engine_version, unreal_project_name, engine_path, From 92000bc21e03d3ddf1fc99a62421ead1f278865f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 15:48:19 +0200 Subject: [PATCH 124/429] :rotating_light: fix hound :dog: --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index efbbed27c8..a635bd4cab 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -233,8 +233,8 @@ class UnrealPrelaunchHook(PreLaunchHook): Path(temp_dir)) try: self.log.info(( - f"Moving from {temp_dir} to " - f"{project_path.as_posix()}" + f"Moving from {temp_dir} to " + f"{project_path.as_posix()}" )) shutil.copytree( temp_dir, project_path, dirs_exist_ok=True) From cd9ec2b73a269d4973db81622dbadb268ec7a24d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Aug 2023 17:40:58 +0200 Subject: [PATCH 125/429] OCIO: adding exception for nuke, hiero into hook --- openpype/hooks/pre_ocio_hook.py | 3 +++ openpype/hosts/nuke/api/lib.py | 1 + 2 files changed, 4 insertions(+) diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 1307ed9f76..add3a0adaf 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -45,6 +45,9 @@ class OCIOEnvHook(PreLaunchHook): if config_data: ocio_path = config_data["path"] + if self.host_name in ["nuke", "hiero"]: + ocio_path = ocio_path.replace("\\", "/") + self.log.info( f"Setting OCIO environment to config path: {ocio_path}") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4a1e109b17..2a6c1fb12c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2177,6 +2177,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. """ # replace path with env var if possible ocio_path = self._replace_ocio_path_with_env_var(config_data) + ocio_path = ocio_path.replace("\\", "/") log.info("Setting OCIO config path to: `{}`".format( ocio_path)) From 77d18d0b060d009a80102f00a3f76b31f488bd03 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:20:33 +0300 Subject: [PATCH 126/429] make hound happy --- .../houdini/plugins/create/create_fbx.py | 32 +++++++++---------- .../hosts/houdini/plugins/load/load_fbx.py | 10 +++--- .../plugins/publish/collect_fbx_type.py | 1 - .../publish/validate_fbx_hierarchy_path.py | 1 + 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 2bfdb3e729..1ff63ab2c4 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -69,19 +69,19 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", - items={ - 0: "Maya Compatible (MC)", - 1: "3DS MAX Compatible (PC2)" - }, - default=0, - label="Vertex Cache Format") + items={ + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") trange = EnumDef("trange", - items={ - 0: "Render Current Frame", - 1: "Render Frame Range" - }, - default=0, - label="Valid Frame Range") + items={ + 0: "Render Current Frame", + 1: "Render Frame Range" + }, + default=0, + label="Valid Frame Range") return attrs + [vcformat, trange] @@ -103,10 +103,10 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # parms dictionary parms = { - "startnode" : selection, + "startnode": selection, "sopoutput": output_path, - "vcformat" : vcformat, - "trange" : trange + "vcformat": vcformat, + "trange": trange } return parms @@ -139,7 +139,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): ) # Allow object level paths to Geometry nodes (e.g. /obj/geo1) - # but do not allow other object level nodes types like cameras, etc. + # but do not allow other object level nodes types like cameras. elif isinstance(selected_node, hou.ObjNode) and \ selected_node.type().name() in ["geo"]: diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 5294f5248d..34f75e1485 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -126,19 +126,19 @@ class FbxLoader(load.LoaderPlugin): file_node.destroy() # Create a new file node - file_node = parent_node.createNode("file", node_name= node_name) - file_node.setParms({"file":file_path}) + file_node = parent_node.createNode("file", node_name=node_name) + file_node.setParms({"file": file_path}) # Create attribute delete attribdelete_name = "attribdelete_{}".format(subset_name) attribdelete = parent_node.createNode("attribdelete", - node_name= attribdelete_name) - attribdelete.setParms({"ptdel":"fbx_*"}) + node_name=attribdelete_name) + attribdelete.setParms({"ptdel": "fbx_*"}) attribdelete.setInput(0, file_node) # Create a Null node null_name = "OUT_{}".format(subset_name) - null = parent_node.createNode("null", node_name= null_name) + null = parent_node.createNode("null", node_name=null_name) null.setInput(0, attribdelete) # Ensure display flag is on the file_node input node and not on the OUT diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 794d8bd6e7..3ee2541f72 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -42,7 +42,6 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # to Update instance.data with ouptut_node of different types # however, this collector is used for demonstration - def get_output_node(self, instance): """Getting output_node Logic.""" diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index e98e562fe8..9208a16bd1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -29,6 +29,7 @@ from openpype.hosts.houdini.api.action import ( import hou + # Each validation can have a single repair action # which calls the repair method class AddDefaultPathAction(RepairAction): From 0fe908a7cc4e2130e36f52820f9064ba48621d59 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:23:56 +0300 Subject: [PATCH 127/429] make hound happy again --- openpype/hosts/houdini/plugins/create/create_fbx.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 1ff63ab2c4..a92a4a5a24 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -70,11 +70,11 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", items={ - 0: "Maya Compatible (MC)", - 1: "3DS MAX Compatible (PC2)" - }, - default=0, - label="Vertex Cache Format") + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") trange = EnumDef("trange", items={ 0: "Render Current Frame", From 4bfde17a420cc3686734d049728e7eaf66b51538 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:26:21 +0300 Subject: [PATCH 128/429] make hound happy --- openpype/hosts/houdini/plugins/create/create_fbx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index a92a4a5a24..e26fd660ba 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -70,8 +70,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", items={ - 0: "Maya Compatible (MC)", - 1: "3DS MAX Compatible (PC2)" + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" }, default=0, label="Vertex Cache Format") From 433160e75ae70c2a17d5460c6815588bb17c58b0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:29:02 +0300 Subject: [PATCH 129/429] make hound happy again --- openpype/hosts/houdini/plugins/create/create_fbx.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index e26fd660ba..ed95daafca 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -72,16 +72,16 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): items={ 0: "Maya Compatible (MC)", 1: "3DS MAX Compatible (PC2)" - }, + }, default=0, label="Vertex Cache Format") trange = EnumDef("trange", items={ 0: "Render Current Frame", 1: "Render Frame Range" - }, - default=0, - label="Valid Frame Range") + }, + default=0, + label="Valid Frame Range") return attrs + [vcformat, trange] From 58408817999f053118a5c14cff8058c909a512cf Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:30:00 +0300 Subject: [PATCH 130/429] make hound happy --- openpype/hosts/houdini/plugins/create/create_fbx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index ed95daafca..cac90f1e87 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -72,7 +72,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): items={ 0: "Maya Compatible (MC)", 1: "3DS MAX Compatible (PC2)" - }, + }, default=0, label="Vertex Cache Format") trange = EnumDef("trange", From 83508a93eadb1387a49dca465d68bcf0d96badc6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 13:45:20 +0200 Subject: [PATCH 131/429] nuke: fixing thumbnail and monitor out root attributes --- openpype/hosts/nuke/api/lib.py | 54 +++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4a1e109b17..cedbe6d5e6 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2041,6 +2041,7 @@ class WorkfileSettings(object): ) workfile_settings = imageio_host["workfile"] + viewer_process_settings = imageio_host["viewer"]["viewerProcess"] if not config_data: # TODO: backward compatibility for old projects - remove later @@ -2091,6 +2092,31 @@ class WorkfileSettings(object): workfile_settings.pop("colorManagement", None) workfile_settings.pop("OCIO_config", None) + # treat monitor lut separately + monitor_lut = workfile_settings.pop("monitorLut", None) + m_display, m_viewer = get_viewer_config_from_string(monitor_lut) + v_display, v_viewer = get_viewer_config_from_string( + viewer_process_settings + ) + + # set monitor lut differently for nuke version 14 + if nuke.NUKE_VERSION_MAJOR >= 14: + workfile_settings["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + workfile_settings["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=False) + + if nuke.NUKE_VERSION_MAJOR == 13: + workfile_settings["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + workfile_settings["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=True) + if nuke.NUKE_VERSION_MAJOR <= 12: + workfile_settings["monitorLut"] = create_viewer_profile_string( + m_viewer, m_display, path_like=True) + # then set the rest for knob, value_ in workfile_settings.items(): # skip unfilled ocio config path @@ -3320,11 +3346,11 @@ def get_viewer_config_from_string(input_string): display = split[0] elif "(" in viewer: pattern = r"([\w\d\s\.\-]+).*[(](.*)[)]" - result = re.findall(pattern, viewer) + result_ = re.findall(pattern, viewer) try: - result = result.pop() - display = str(result[1]).rstrip() - viewer = str(result[0]).rstrip() + result_ = result_.pop() + display = str(result_[1]).rstrip() + viewer = str(result_[0]).rstrip() except IndexError: raise IndexError(( "Viewer Input string is not correct. " @@ -3332,3 +3358,23 @@ def get_viewer_config_from_string(input_string): ).format(input_string)) return (display, viewer) + + +def create_viewer_profile_string(viewer, display=None, path_like=False): + """Convert viewer and display to string + + Args: + viewer (str): viewer name + display (Optional[str]): display name + path_like (Optional[bool]): if True, return path like string + + Returns: + str: viewer config string + """ + if display: + if path_like: + return "{}/{}".format(display, viewer) + else: + return "{} ({})".format(viewer, display) + else: + return viewer From 3603fdfe0012f905634d1786275a2f72b4ca2203 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 15:11:26 +0200 Subject: [PATCH 132/429] Nuke: existing frames validator is repairing render target --- .../publish/validate_rendered_frames.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 45c20412c8..ef3d4d0bb5 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -14,19 +14,28 @@ class RepairActionBase(pyblish.api.Action): # Get the errored instances return get_errored_instances_from_context(context, plugin=plugin) - def repair_knob(self, instances, state): + def repair_knob(self, context, instances, state): + create_context = context.data["create_context"] for instance in instances: - node = instance.data["transientData"]["node"] - files_remove = [os.path.join(instance.data["outputDir"], f) - for r in instance.data.get("representations", []) - for f in r.get("files", []) - ] + files_remove = [ + os.path.join(instance.data["outputDir"], f_) + for r_ in instance.data.get("representations", []) + for f_ in r_.get("files", []) + ] self.log.info("Files to be removed: {}".format(files_remove)) - for f in files_remove: - os.remove(f) - self.log.debug("removing file: {}".format(f)) - node["render"].setValue(state) + for f_ in files_remove: + os.remove(f_) + self.log.debug("removing file: {}".format(f_)) + + # Reset the render knob + instance_id = instance.data["instance_id"] + created_instance = create_context.get_instance_by_id( + instance_id + ) + created_instance.creator_attributes["render_target"] = state + self.log.info("Rendering toggled to `{}`".format(state)) + create_context.save_changes() class RepairCollectionActionToLocal(RepairActionBase): @@ -34,7 +43,7 @@ class RepairCollectionActionToLocal(RepairActionBase): def process(self, context, plugin): instances = self.get_instance(context, plugin) - self.repair_knob(instances, "Local") + self.repair_knob(context, instances, "local") class RepairCollectionActionToFarm(RepairActionBase): @@ -42,7 +51,7 @@ class RepairCollectionActionToFarm(RepairActionBase): def process(self, context, plugin): instances = self.get_instance(context, plugin) - self.repair_knob(instances, "On farm") + self.repair_knob(context, instances, "farm") class ValidateRenderedFrames(pyblish.api.InstancePlugin): From ee8a2b1aa37567ccfc77fe3f31562eca0622f294 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:10:40 +0300 Subject: [PATCH 133/429] update geo validation --- .../publish/validate_fbx_hierarchy_path.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 9208a16bd1..e060756801 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -17,7 +17,10 @@ This plugin is part of publish process guide. """ import pyblish.api -from openpype.pipeline import PublishValidationError +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, @@ -37,7 +40,8 @@ class AddDefaultPathAction(RepairAction): icon = "mdi.pencil-plus-outline" -class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): +class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validate all primitives build hierarchy from attribute when enabled. @@ -50,7 +54,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): families = ["filmboxfbx"] hosts = ["houdini"] - label = "Validate FBX Hierarchy Path" + label = "Validate Prims Hierarchy Path (FBX)" # Usually you will use this value as default order = ValidateContentsOrder + 0.1 @@ -61,6 +65,10 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): actions = [SelectInvalidAction, AddDefaultPathAction, SelectROPAction] + # 'OptionalPyblishPluginMixin' where logic for 'optional' is implemented. + # It requires updating project settings + optional = True + # overrides InstancePlugin.process() def process(self, instance): invalid = self.get_invalid(instance) @@ -108,19 +116,21 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): cls.log.debug("Checking for attribute: %s", path_attr) - if not hasattr(output_node, "geometry"): - # In the case someone has explicitly set an Object - # node instead of a SOP node in Geometry context - # then for now we ignore - this allows us to also - # export object transforms. - cls.log.warning("No geometry output node found," + # Get frame + frame = hou.intFrame() + trange = rop_node.parm("trange").eval() + if trange: + frame = int(hou.playbar.frameRange()[0]) + + frame = instance.data.get("frameStart", frame) + + # Get Geo at that frame + geo = output_node.geometryAtFrame(frame) + if not geo: + cls.log.warning("No geometry found," " skipping check..") return - # Check if the primitive attribute exists - frame = instance.data.get("frameStart", 0) - geo = output_node.geometryAtFrame(frame) - # If there are no primitives on the current frame then # we can't check whether the path names are correct. # So we'll just issue a warning that the check can't From 1d0c78f044a4f0d5dd6092968ff543f09d5c5987 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:11:04 +0300 Subject: [PATCH 134/429] update label --- .../plugins/publish/validate_primitive_hierarchy_paths.py | 2 +- 1 file changed, 1 insertion(+), 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 471fa5b6d1..930978ef16 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -26,7 +26,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): order = ValidateContentsOrder + 0.1 families = ["abc"] hosts = ["houdini"] - label = "Validate Prims Hierarchy Path" + label = "Validate Prims Hierarchy Path (ABC)" actions = [AddDefaultPathAction] def process(self, instance): From 4ea1f2b586835f6a6cad97633d5149fc614ce991 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:11:47 +0300 Subject: [PATCH 135/429] include fbx validator in settings --- openpype/settings/defaults/project_settings/houdini.json | 5 +++++ .../projects_schema/schemas/schema_houdini_publish.json | 6 +++++- server_addon/houdini/server/settings/publish_plugins.py | 8 ++++++++ server_addon/houdini/server/version.py | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 9d047c28bd..2295422202 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -97,6 +97,11 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateFBXPrimitiveHierarchyPaths": { + "enabled": true, + "optional": true, + "active": true } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index aa6eaf5164..d58b36eff1 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -43,8 +43,12 @@ { "key": "ValidateContainers", "label": "ValidateContainers" + }, + { + "key": "ValidateFBXPrimitiveHierarchyPaths", + "label": "Validate Path Attribute for FBX" } ] } ] -} \ No newline at end of file +} diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 7d35d7e634..44ff00c318 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -133,6 +133,9 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Latest Containers.") + ValidateFBXPrimitiveHierarchyPaths: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Path Attribute for FBX.") DEFAULT_HOUDINI_PUBLISH_SETTINGS = { @@ -152,5 +155,10 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "enabled": True, "optional": True, "active": True + }, + "ValidateFBXPrimitiveHierarchyPaths": { + "enabled": True, + "optional": True, + "active": True } } diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From 96893f6ab0a56db976aa14709f2fbbd734b70f88 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:56:29 +0300 Subject: [PATCH 136/429] update doc string --- openpype/hosts/houdini/plugins/create/create_fbx.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index cac90f1e87..b45aef8fdf 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -67,6 +67,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # Overrides HoudiniCreator.get_pre_create_attr_defs() def get_pre_create_attr_defs(self): + """Add settings for users. """ + attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", items={ From fa80317f6a6fb8ac5f65d11304444cf128b0567a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 24 Aug 2023 17:08:37 +0800 Subject: [PATCH 137/429] namespace fix for most loaders except alembic loaders --- openpype/hosts/max/api/lib.py | 9 ++++ .../hosts/max/plugins/load/load_camera_fbx.py | 32 ++++++++----- .../hosts/max/plugins/load/load_max_scene.py | 48 +++++++++---------- openpype/hosts/max/plugins/load/load_model.py | 7 +++ .../hosts/max/plugins/load/load_model_fbx.py | 43 ++++++++++------- .../hosts/max/plugins/load/load_model_obj.py | 38 ++++++++------- .../hosts/max/plugins/load/load_model_usd.py | 43 ++++++++++------- .../hosts/max/plugins/load/load_pointcache.py | 8 ++++ .../hosts/max/plugins/load/load_pointcloud.py | 24 ++++++---- .../max/plugins/load/load_redshift_proxy.py | 26 +++++----- 10 files changed, 168 insertions(+), 110 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index e357080cbc..08819ba155 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -368,3 +368,12 @@ def unique_namespace(namespace, format="%02d", else: increment_version = True iteration += 1 + + +def get_namespace(container_name): + node = rt.getNodeByName(container_name) + if not node: + raise RuntimeError("Master Container Not Found..") + name = rt.getUserProp(node, "name") + namespace = rt.getUserProp(node, "namespace") + return namespace, name diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index c0e1172a6d..c70ece6293 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -33,16 +35,17 @@ class FbxLoader(load.LoaderPlugin): rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.container(name=name) - selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) - for selection in selections: - selection.Parent = container - namespace = unique_namespace( name + "_", suffix="_", ) + container = rt.container(name=f"{namespace}:{name}") + selections = rt.GetCurrentSelection() + import_custom_attribute_data(container, selections) + + for selection in selections: + selection.Parent = container + selection.name = f"{namespace}:{selection.name}" return containerise( name, [container], context, @@ -54,11 +57,13 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") - - inst_container = rt.getNodeByName(param_container) - rt.Select(node.Children) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + for prev_fbx_obj in rt.selection: + if rt.isValidNode(prev_fbx_obj): + rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) @@ -71,10 +76,11 @@ class FbxLoader(load.LoaderPlugin): for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container + fbx_object.name = f"{namespace}:{fbx_object.name}" for children in node.Children: if rt.classOf(children) == rt.Container: - if children.name == param_container: + if children.name == sub_node_name: update_custom_attribute_data( children, current_fbx_objects) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index aa177291d8..cf5f7736e3 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data @@ -29,17 +31,21 @@ class MaxSceneLoader(load.LoaderPlugin): path = path.replace('\\', '/') rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() + max_object_names = [obj.name for obj in max_objects] # implement the OP/AYON custom attributes before load max_container = [] - container = rt.Container(name=name) - import_custom_attribute_data(container, max_objects) - max_container.append(container) - max_container.extend(max_objects) namespace = unique_namespace( name + "_", suffix="_", ) + container_name = f"{namespace}:{name}" + container = rt.Container(name=container_name) + import_custom_attribute_data(container, max_objects) + max_container.append(container) + max_container.extend(max_objects) + for max_obj, obj_name in zip(max_objects, max_object_names): + max_obj.name = f"{namespace}:{obj_name}" return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -51,34 +57,28 @@ class MaxSceneLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") - + namespace, name = get_namespace(node_name) + sub_container_name = f"{namespace}:{name}" # delete the old container with attribute # delete old duplicate - prev_max_object_names = [obj.name for obj - in rt.getLastMergedNodes()] + #TODO: get the prev_max_objects by using node.Children + rt.Select(node.Children) + for prev_max_obj in rt.GetCurrentSelection(): + if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa + rt.Delete(prev_max_obj) rt.MergeMaxFile(path, rt.Name("deleteOldDups")) current_max_objects = rt.getLastMergedNodes() current_max_object_names = [obj.name for obj in current_max_objects] - for name in current_max_object_names: - idx = rt.findItem(prev_max_object_names, name) - if idx: - prev_max_object_names = rt.deleteItem( - prev_max_object_names, idx) - for object_name in prev_max_object_names: - prev_max_object = rt.getNodeByName(object_name) - rt.Delete(prev_max_object) - + sub_container = rt.getNodeByName(sub_container_name) + update_custom_attribute_data(sub_container, current_max_objects) for max_object in current_max_objects: max_object.Parent = node - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == param_container: - update_custom_attribute_data( - children, current_max_objects) + for max_obj, obj_name in zip( + current_max_objects, current_max_object_names): + max_obj.name = f"{namespace}:{obj_name}" + lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index deb3389992..aee948f2e2 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -58,6 +58,13 @@ class ModelAbcLoader(load.LoaderPlugin): name + "_", suffix="_", ) + for abc_object in abc_container.Children: + abc_object.name = f"{namespace}:{abc_object.name}" + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}" + abc_container.name = abc_container_name + # get the correct container + abc_container = rt.GetNodeByName(abc_container_name) return containerise( name, [abc_container], context, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index f85bfa03a1..6097a4ca6e 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -5,7 +5,9 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.lib import maintained_selection @@ -28,20 +30,18 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(name) - - container = rt.Container(name=name) - + namespace = unique_namespace( + name + "_", + suffix="_", + ) + container = rt.container(name=f"{namespace}:{name}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) for selection in selections: selection.Parent = container + selection.name = f"{namespace}:{selection.name}" - namespace = unique_namespace( - name + "_", - suffix="_", - ) return containerise( name, [container], context, namespace, loader=self.__class__.__name__ @@ -52,8 +52,14 @@ class FbxModelLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + for prev_fbx_obj in rt.selection: + if rt.isValidNode(prev_fbx_obj): + rt.Delete(prev_fbx_obj) + rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("merge")) @@ -61,16 +67,17 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() - - inst_container = rt.getNodeByName(param_container) - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == param_container: - update_custom_attribute_data( - children, current_fbx_objects) for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container + fbx_object.name = f"{namespace}:{fbx_object.name}" + + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == sub_node_name: + update_custom_attribute_data( + children, current_fbx_objects) + with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index b42ef399b0..225801b8d0 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, @@ -27,18 +29,19 @@ class ObjLoader(load.LoaderPlugin): self.log.debug("Executing command to import..") rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') - # create "missing" container for obj import - container = rt.Container(name=name) - selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) - # get current selection - for selection in selections: - selection.Parent = container namespace = unique_namespace( name + "_", suffix="_", ) + # create "missing" container for obj import + container = rt.Container(name=f"{namespace}:{name}") + selections = rt.GetCurrentSelection() + import_custom_attribute_data(container, selections) + # get current selection + for selection in selections: + selection.Parent = container + selection.name = f"{namespace}:{selection.name}" return containerise( name, [container], context, namespace, loader=self.__class__.__name__) @@ -48,21 +51,22 @@ class ObjLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.GetNodeByName(node_name) - - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") - - inst_container = rt.getNodeByName(param_container) - for child in inst_container.Children: - rt.Delete(child) + node = rt.getNodeByName(node_name) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + for prev_obj in rt.selection: + if rt.isValidNode(prev_obj): + rt.Delete(prev_obj) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection selections = rt.GetCurrentSelection() + update_custom_attribute_data(inst_container, selections) for selection in selections: selection.Parent = inst_container - update_custom_attribute_data(inst_container, selections) + selection.name = f"{namespace}:{selection.name}" with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 4febba216e..0c17736739 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,12 +1,13 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + import_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -35,14 +36,20 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - asset = rt.GetNodeByName(name) - - import_custom_attribute_data(asset, asset.Children) - namespace = unique_namespace( name + "_", suffix="_", ) + asset = rt.GetNodeByName(name) + import_custom_attribute_data(asset, asset.Children) + for usd_asset in asset.Children: + usd_asset.name = f"{namespace}:{usd_asset.name}" + + asset_name = f"{namespace}:{name}" + asset.name = asset_name + # need to get the correct container after renamed + asset = rt.GetNodeByName(asset_name) + return containerise( name, [asset], context, @@ -54,12 +61,14 @@ class ModelUSDLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.GetNodeByName(node_name) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" for n in node.Children: - for r in n.Children: - rt.Delete(r) + rt.Select(n.Children) + for prev_usd_asset in rt.selection: + if rt.isValidNode(prev_usd_asset): + rt.Delete(prev_usd_asset) rt.Delete(n) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(path) @@ -71,13 +80,13 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile( path, importOptions=import_options) - asset = rt.GetNodeByName(param_container) + asset = rt.GetNodeByName(name) asset.Parent = node - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == param_container: - update_custom_attribute_data( - asset, asset.Children) + import_custom_attribute_data(asset, asset.Children) + for children in asset.Children: + children.name = f"{namespace}:{children.name}" + asset.name = sub_node_name + with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index af03e70236..ca833a383c 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -65,6 +65,14 @@ class AbcLoader(load.LoaderPlugin): suffix="_", ) + for abc_object in abc_container.Children: + abc_object.name = f"{namespace}:{abc_object.name}" + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}" + abc_container.name = abc_container_name + # get the correct container + abc_container = rt.GetNodeByName(abc_container_name) + return containerise( name, [abc_container], context, namespace, loader=self.__class__.__name__ diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 6c94fb7847..87b7fce292 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -26,14 +28,15 @@ class PointCloudLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.Container(name=name) - obj.Parent = prt_container - import_custom_attribute_data(prt_container, [obj]) namespace = unique_namespace( name + "_", suffix="_", ) + prt_container = rt.Container(name=f"{namespace}:{name}") + import_custom_attribute_data(prt_container, [obj]) + obj.Parent = prt_container + obj.name = f"{namespace}:{obj.name}" return containerise( name, [prt_container], context, @@ -45,14 +48,15 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) + namespace, name = get_namespace(container["instance_node"]) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + update_custom_attribute_data( + inst_container, inst_container.Children) with maintained_selection(): rt.Select(node.Children) - for sub_node in rt.Selection: - children_node = sub_node.Children - update_custom_attribute_data( - sub_node, sub_node.Children) - for prt in children_node: - prt.filename = path + for prt in inst_container.Children: + prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 1c4cd02143..a64bfa7de2 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -11,7 +11,9 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) class RedshiftProxyLoader(load.LoaderPlugin): @@ -35,14 +37,15 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - container = rt.Container(name=name) - rs_proxy.Parent = container - import_custom_attribute_data(container, [rs_proxy]) namespace = unique_namespace( name + "_", suffix="_", ) + container = rt.Container(name=f"{namespace}:{name}") + rs_proxy.Parent = container + rs_proxy.name = f"{namespace}:{rs_proxy.name}" + import_custom_attribute_data(container, [rs_proxy]) return containerise( name, [container], context, @@ -52,13 +55,14 @@ class RedshiftProxyLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - for sub_node in node.Children: - children_node = sub_node.Children - update_custom_attribute_data( - sub_node, children_node) - for proxy in children_node: - proxy.file = path + namespace, name = get_namespace(container["instance_node"]) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + + update_custom_attribute_data( + inst_container, inst_container.Children) + for proxy in inst_container.Children: + proxy.file = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) From 637c6396cad3bfe85f8537d969232986694f9af4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 24 Aug 2023 17:13:38 +0800 Subject: [PATCH 138/429] hound --- openpype/hosts/max/plugins/load/load_max_scene.py | 5 ++--- openpype/hosts/max/plugins/load/load_redshift_proxy.py | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index cf5f7736e3..fada871c6d 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -61,7 +61,6 @@ class MaxSceneLoader(load.LoaderPlugin): sub_container_name = f"{namespace}:{name}" # delete the old container with attribute # delete old duplicate - #TODO: get the prev_max_objects by using node.Children rt.Select(node.Children) for prev_max_obj in rt.GetCurrentSelection(): if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa @@ -75,8 +74,8 @@ class MaxSceneLoader(load.LoaderPlugin): update_custom_attribute_data(sub_container, current_max_objects) for max_object in current_max_objects: max_object.Parent = node - for max_obj, obj_name in zip( - current_max_objects, current_max_object_names): + for max_obj, obj_name in zip(current_max_objects, + current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index a64bfa7de2..b240714314 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -37,7 +37,6 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - namespace = unique_namespace( name + "_", suffix="_", From d0857a63e0a9c107fdd9158c032b67afcd70d941 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 24 Aug 2023 17:27:52 +0800 Subject: [PATCH 139/429] alembic loader namespace fix --- openpype/hosts/max/plugins/load/load_model.py | 2 -- openpype/hosts/max/plugins/load/load_pointcache.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index aee948f2e2..acc2a4032b 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -63,8 +63,6 @@ class ModelAbcLoader(load.LoaderPlugin): # rename the abc container with namespace abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name - # get the correct container - abc_container = rt.GetNodeByName(abc_container_name) return containerise( name, [abc_container], context, diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index ca833a383c..64bf7ddac0 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -70,8 +70,6 @@ class AbcLoader(load.LoaderPlugin): # rename the abc container with namespace abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name - # get the correct container - abc_container = rt.GetNodeByName(abc_container_name) return containerise( name, [abc_container], context, From 93f897c780d5b6bda9ee56e013b12ee7b0ba6952 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Aug 2023 13:59:34 +0200 Subject: [PATCH 140/429] deadline: adding collect farm target and removing redundant key --- .../plugins/publish/collect_render.py | 1 - .../fusion/plugins/publish/collect_render.py | 1 - .../plugins/publish/collect_farm_render.py | 1 - .../plugins/publish/submit_maya_muster.py | 1 - .../publish/submit_celaction_deadline.py | 2 +- .../publish/submit_houdini_render_deadline.py | 1 - .../plugins/publish/submit_max_deadline.py | 1 - .../plugins/publish/submit_maya_deadline.py | 1 - .../plugins/publish/submit_nuke_deadline.py | 1 - .../publish/abstract_collect_render.py | 1 - .../plugins/publish/collect_farm_target.py | 45 +++++++++++++++++++ 11 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 openpype/plugins/publish/collect_farm_target.py diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index aa46461915..49874d6cff 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -138,7 +138,6 @@ class CollectAERender(publish.AbstractCollectRender): fam = "render.farm" if fam not in instance.families: instance.families.append(fam) - instance.toBeRenderedOn = "deadline" instance.renderer = "aerender" instance.farm = True # to skip integrate if "review" in instance.families: diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index a20a142701..341f3f191a 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -108,7 +108,6 @@ class CollectFusionRender( fam = "render.farm" if fam not in instance.families: instance.families.append(fam) - instance.toBeRenderedOn = "deadline" instance.farm = True # to skip integrate if "review" in instance.families: # to skip ExtractReview locally diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 5e9b9094a7..5daa93cddb 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -174,7 +174,6 @@ class CollectFarmRender(publish.AbstractCollectRender): outputFormat=info[1], outputStartFrame=info[3], leadingZeros=info[2], - toBeRenderedOn='deadline', ignoreFrameHandleCheck=True ) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index 8e219eae85..b79c9ed140 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -249,7 +249,6 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): Authenticate with Muster, collect all data, prepare path for post render publish job and submit job to farm. """ - instance.data["toBeRenderedOn"] = "muster" # setup muster environment self.MUSTER_REST_URL = os.environ.get("MUSTER_REST_URL") diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index ee28612b44..4aef914023 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -27,7 +27,7 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): deadline_job_delay = "00:00:08:00" def process(self, instance): - instance.data["toBeRenderedOn"] = "deadline" + context = instance.context # get default deadline webservice url from deadline module 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 108c377078..8f21a920be 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -141,4 +141,3 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): # Store output dir for unified publisher (filesequence) output_dir = os.path.dirname(instance.data["files"][0]) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 8e05582962..5d38903770 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -174,7 +174,6 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, first_file = next(self._iter_expected_files(files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" filename = os.path.basename(filepath) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 75d24b28f0..34f3905a17 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -300,7 +300,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" # Patch workfile (only when use_published is enabled) if self.use_published: diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index cfdeb4968b..ded5cd179f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -97,7 +97,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, instance.data["suspend_publish"] = instance.data["attributeValues"][ "suspend_publish"] - instance.data["toBeRenderedOn"] = "deadline" families = instance.data["families"] node = instance.data["transientData"]["node"] diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py index 6877d556c3..8a26402bd8 100644 --- a/openpype/pipeline/publish/abstract_collect_render.py +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -75,7 +75,6 @@ class RenderInstance(object): tilesY = attr.ib(default=0) # number of tiles in Y # submit_publish_job - toBeRenderedOn = attr.ib(default=None) deadlineSubmissionJob = attr.ib(default=None) anatomyData = attr.ib(default=None) outputDir = attr.ib(default=None) diff --git a/openpype/plugins/publish/collect_farm_target.py b/openpype/plugins/publish/collect_farm_target.py new file mode 100644 index 0000000000..78410835dd --- /dev/null +++ b/openpype/plugins/publish/collect_farm_target.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +import pyblish.api + + +class CollectFarmTarget(pyblish.api.InstancePlugin): + """Collects the render target for the instance + """ + + order = pyblish.api.CollectorOrder + 0.499 + label = "Collect Farm Target" + targets = ["local"] + + def process(self, instance): + if not instance.data.get("farm"): + return + + context = instance.context + try: + deadline_module = context.data.get("openPypeModules")["deadline"] + if deadline_module.enabled: + instance.data["toBeRenderedOn"] = "deadline" + self.log.debug("Collected render target: deadline") + except AttributeError: + self.log.error("Cannot get OpenPype Deadline module.") + raise AssertionError("OpenPype Deadline module not found.") + + try: + royalrender_module = \ + context.data.get("openPypeModules")["royalrender"] + if royalrender_module.enabled: + instance.data["toBeRenderedOn"] = "royalrender" + self.log.debug("Collected render target: royalrender") + + except AttributeError: + self.log.error("Cannot get OpenPype RoyalRender module.") + raise AssertionError("OpenPype RoyalRender module not found.") + + try: + muster_module = context.data.get("openPypeModules")["muster"] + if muster_module.enabled: + instance.data["toBeRenderedOn"] = "muster" + self.log.debug("Collected render target: muster") + except AttributeError: + self.log.error("Cannot get OpenPype Muster module.") + raise AssertionError("OpenPype Muster module not found.") From e181eeab93db55b7804f3b6f5055369e1a30ede7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 24 Aug 2023 16:02:18 +0300 Subject: [PATCH 141/429] convert filmboxfbx to UE static mesh --- ...ate_fbx.py => create_unreal_staticmesh.py} | 25 ++++++--------- .../hosts/houdini/plugins/load/load_fbx.py | 2 +- .../plugins/publish/collect_fbx_type.py | 10 +++--- .../houdini/plugins/publish/extract_fbx.py | 4 +-- .../publish/validate_fbx_hierarchy_path.py | 2 +- .../publish/validate_sop_output_node.py | 2 +- openpype/plugins/publish/integrate.py | 3 +- .../defaults/project_settings/houdini.json | 13 ++++++++ .../schemas/schema_houdini_create.json | 31 +++++++++++++++++++ .../server/settings/publish_plugins.py | 29 +++++++++++++++++ 10 files changed, 94 insertions(+), 27 deletions(-) rename openpype/hosts/houdini/plugins/create/{create_fbx.py => create_unreal_staticmesh.py} (89%) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py similarity index 89% rename from openpype/hosts/houdini/plugins/create/create_fbx.py rename to openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index b45aef8fdf..4543f14934 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -20,17 +20,18 @@ from openpype.lib import EnumDef import hou -class CreateFilmboxFBX(plugin.HoudiniCreator): +class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): """Filmbox FBX Driver.""" # you should set - identifier = "io.openpype.creators.houdini.filmboxfbx" - label = "Filmbox FBX" - family = "filmboxfbx" + identifier = "io.openpype.creators.houdini.unrealstaticmesh" + label = "Unreal - Static Mesh" + family = "staticMesh" icon = "fa5s.cubes" # optional to set default_variant = "Main" + # 'default_variants' will be overriden by settings. default_variants = ["Main", "Test"] # Overrides HoudiniCreator.create() @@ -40,7 +41,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): instance_data.update({"node_type": "filmboxfbx"}) # create instance (calls HoudiniCreator.create()) - instance = super(CreateFilmboxFBX, self).create( + instance = super(HouCreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) @@ -77,15 +78,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): }, default=0, label="Vertex Cache Format") - trange = EnumDef("trange", - items={ - 0: "Render Current Frame", - 1: "Render Frame Range" - }, - default=0, - label="Valid Frame Range") - return attrs + [vcformat, trange] + return attrs + [vcformat] def get_parms(self, subset_name, pre_create_data): """Get parameters values for this specific node.""" @@ -100,8 +94,9 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # 3. get Vertex Cache Format vcformat = pre_create_data.get("vcformat") - # 4. get Valid Frame Range - trange = pre_create_data.get("trange") + # 4. Valid Frame Range + # It should publish the current frame. + trange = 0 # parms dictionary parms = { diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 34f75e1485..d661f84eeb 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -20,7 +20,7 @@ class FbxLoader(load.LoaderPlugin): """Load fbx files to Houdini.""" label = "Load FBX" - families = ["filmboxfbx", "fbx"] + families = ["staticMesh", "fbx"] representations = ["fbx"] # Usually you will use these value as default diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 3ee2541f72..4c83829c67 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -13,11 +13,11 @@ import pyblish.api class CollectFilmboxfbxType(pyblish.api.InstancePlugin): - """Collect data type for filmboxfbx instance.""" + """Collect data type for fbx instance.""" hosts = ["houdini"] - families = ["filmboxfbx"] - label = "Collect type of filmboxfbx" + families = ["fbx", "staticMesh"] + label = "Collect type of fbx" # Usually you will use this value as default order = pyblish.api.CollectorOrder @@ -25,12 +25,12 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # overrides InstancePlugin.process() def process(self, instance): - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh": # noqa: E501 # such a condition can be used to differentiate between # instances by identifier becuase sometimes instances # may have the same family but different identifier # e.g. bgeo and alembic - pass + instance.data["families"] += ["fbx"] # Update instance.data with ouptut_node out_node = self.get_output_node(instance) diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 102b075838..8e45a554c0 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -18,8 +18,8 @@ import hou class ExtractRedshiftProxy(publish.Extractor): - label = "Extract FilmBox FBX" - families = ["filmboxfbx"] + label = "Extract FBX" + families = ["fbx"] hosts = ["houdini"] # Usually you will use this value as default diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index e060756801..01cb01f497 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -52,7 +52,7 @@ class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, by default. """ - families = ["filmboxfbx"] + families = ["fbx"] hosts = ["houdini"] label = "Validate Prims Hierarchy Path (FBX)" diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index da9752505a..2b426d96dd 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -22,7 +22,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache", "filmboxfbx"] + families = ["pointcache", "vdbcache", "fbx"] hosts = ["houdini"] label = "Validate Output Node" actions = [SelectROPAction, SelectInvalidAction] diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index ee4af1a0e0..be07cffe72 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -139,8 +139,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene", - "filmboxfbx" + "blendScene" ] default_template_name = "publish" diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 2295422202..e19e71de17 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -19,6 +19,19 @@ ], "ext": ".ass" }, + "HouCreateUnrealStaticMesh": { + "enabled": true, + "default_variants": [ + "Main" + ], + "static_mesh_prefix": "S", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] + }, "CreateAlembicCamera": { "enabled": true, "default_variants": [ 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 799bc0e81a..3d55bd834f 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 @@ -39,6 +39,37 @@ ] }, + { + "type": "dict", + "collapsible": true, + "key": "HouCreateUnrealStaticMesh", + "label": "Create Unreal - Static Mesh", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Variants", + "object_type": "text" + }, + { + "type": "text", + "key": "static_mesh_prefix", + "label": "Static Mesh Prefix" + }, + { + "type": "list", + "key": "collision_prefixes", + "label": "Collision Mesh Prefixes", + "object_type": "text" + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 44ff00c318..5ddfa07bc4 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -20,11 +20,27 @@ class CreateArnoldAssModel(BaseSettingsModel): ) ext: str = Field(Title="Extension") +class HouCreateUnrealStaticMeshModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + default_variants: list[str] = Field( + default_factory=list, + title="Default Products" + ) + static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix") + collision_prefixes: list[str] = Field( + default_factory=list, + title="Collision Prefixes" + ) class CreatePluginsModel(BaseSettingsModel): CreateArnoldAss: CreateArnoldAssModel = Field( default_factory=CreateArnoldAssModel, title="Create Alembic Camera") + # "-" is not compatible in the new model + HouCreateUnrealStaticMesh: HouCreateUnrealStaticMeshModel = Field( + default_factory=HouCreateUnrealStaticMeshModel, + title="Create Unreal_Static Mesh" + ) CreateAlembicCamera: CreatorModel = Field( default_factory=CreatorModel, title="Create Alembic Camera") @@ -63,6 +79,19 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"], "ext": ".ass" }, + "HouCreateUnrealStaticMesh": { + "enabled": True, + "default_variants": [ + "Main" + ], + "static_mesh_prefix": "S", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] + }, "CreateAlembicCamera": { "enabled": True, "default_variants": ["Main"] From b6a81bd64b74c60e2b0e8e271f7f2704b6b80bf7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 24 Aug 2023 16:06:21 +0300 Subject: [PATCH 142/429] resolve hound conversations --- .../hosts/houdini/plugins/create/create_unreal_staticmesh.py | 3 ++- server_addon/houdini/server/settings/publish_plugins.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 4543f14934..2a284f7979 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -154,7 +154,8 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): if not selection: self.log.debug( - "Selection isn't valid. 'Export' in filmboxfbx will be empty." + "Selection isn't valid. 'Export' in " + "filmboxfbx will be empty." ) else: self.log.debug( diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 5ddfa07bc4..aaa1d0ba1d 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -20,6 +20,7 @@ class CreateArnoldAssModel(BaseSettingsModel): ) ext: str = Field(Title="Extension") + class HouCreateUnrealStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") default_variants: list[str] = Field( @@ -32,6 +33,7 @@ class HouCreateUnrealStaticMeshModel(BaseSettingsModel): title="Collision Prefixes" ) + class CreatePluginsModel(BaseSettingsModel): CreateArnoldAss: CreateArnoldAssModel = Field( default_factory=CreateArnoldAssModel, From 098bacddb9e87ac5eb7c8b9e5f1ef2d3f43fa74f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 11:54:44 +0800 Subject: [PATCH 143/429] fix incorrect position of the container during updating --- openpype/hosts/max/api/lib.py | 32 +++++++++++++++++++ .../hosts/max/plugins/load/load_camera_fbx.py | 11 ++++++- .../hosts/max/plugins/load/load_max_scene.py | 12 +++++-- .../hosts/max/plugins/load/load_model_fbx.py | 11 ++++++- .../hosts/max/plugins/load/load_model_obj.py | 12 ++++++- .../hosts/max/plugins/load/load_model_usd.py | 14 ++++++-- 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 08819ba155..267e75e5fe 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -371,9 +371,41 @@ def unique_namespace(namespace, format="%02d", def get_namespace(container_name): + """Get the namespace and name of the sub-container + + Args: + container_name (str): the name of master container + + Raises: + RuntimeError: when there is no master container found + + Returns: + namespace (str): namespace of the sub-container + name (str): name of the sub-container + """ node = rt.getNodeByName(container_name) if not node: raise RuntimeError("Master Container Not Found..") name = rt.getUserProp(node, "name") namespace = rt.getUserProp(node, "namespace") return namespace, name + +def object_transform_set(container_children): + """A function which allows to store the transform of + previous loaded object(s) + Args: + container_children(list): A list of nodes + + Returns: + transform_set (dict): A dict with all transform data of + the previous loaded object(s) + """ + transform_set = {} + for node in container_children: + name = f"{node.name}.transform" + transform_set[name] = node.pos + name = f"{node.name}.scale" + transform_set[name] = node.scale + name = f"{node.name}.rotation" + transform_set[name] = node.rotation + return transform_set diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index c70ece6293..acd77ad686 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.pipeline import ( containerise, @@ -61,6 +63,7 @@ class FbxLoader(load.LoaderPlugin): sub_node_name = f"{namespace}:{name}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) for prev_fbx_obj in rt.selection: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) @@ -77,6 +80,12 @@ class FbxLoader(load.LoaderPlugin): if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.pos = transform_data[ + f"{fbx_object.name}.transform"] + fbx_object.rotation = transform_data[ + f"{fbx_object.name}.rotation"] + fbx_object.scale = transform_data[ + f"{fbx_object.name}.scale"] for children in node.Children: if rt.classOf(children) == rt.Container: diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index fada871c6d..3d524e261f 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -62,6 +64,7 @@ class MaxSceneLoader(load.LoaderPlugin): # delete the old container with attribute # delete old duplicate rt.Select(node.Children) + transform_data = object_transform_set(node.Children) for prev_max_obj in rt.GetCurrentSelection(): if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa rt.Delete(prev_max_obj) @@ -77,7 +80,12 @@ class MaxSceneLoader(load.LoaderPlugin): for max_obj, obj_name in zip(current_max_objects, current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" - + max_obj.pos = transform_data[ + f"{max_obj.name}.transform"] + max_obj.rotation = transform_data[ + f"{max_obj.name}.rotation"] + max_obj.scale = transform_data[ + f"{max_obj.name}.scale"] lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 6097a4ca6e..fcac72dae1 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -6,7 +6,9 @@ from openpype.hosts.max.api.pipeline import ( ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection @@ -56,6 +58,7 @@ class FbxModelLoader(load.LoaderPlugin): sub_node_name = f"{namespace}:{name}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) for prev_fbx_obj in rt.selection: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) @@ -71,6 +74,12 @@ class FbxModelLoader(load.LoaderPlugin): if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.pos = transform_data[ + f"{fbx_object.name}.transform"] + fbx_object.rotation = transform_data[ + f"{fbx_object.name}.rotation"] + fbx_object.scale = transform_data[ + f"{fbx_object.name}.scale"] for children in node.Children: if rt.classOf(children) == rt.Container: diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 225801b8d0..04a0ac1679 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,10 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + maintained_selection, + object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( @@ -56,6 +59,7 @@ class ObjLoader(load.LoaderPlugin): sub_node_name = f"{namespace}:{name}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) for prev_obj in rt.selection: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) @@ -67,6 +71,12 @@ class ObjLoader(load.LoaderPlugin): for selection in selections: selection.Parent = inst_container selection.name = f"{namespace}:{selection.name}" + selection.pos = transform_data[ + f"{selection.name}.transform"] + selection.rotation = transform_data[ + f"{selection.name}.rotation"] + selection.scale = transform_data[ + f"{selection.name}.scale"] with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 0c17736739..14f339f039 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( @@ -63,8 +65,10 @@ class ModelUSDLoader(load.LoaderPlugin): node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) sub_node_name = f"{namespace}:{name}" + transform_data = None for n in node.Children: rt.Select(n.Children) + transform_data = object_transform_set(n.Children) for prev_usd_asset in rt.selection: if rt.isValidNode(prev_usd_asset): rt.Delete(prev_usd_asset) @@ -85,8 +89,14 @@ class ModelUSDLoader(load.LoaderPlugin): import_custom_attribute_data(asset, asset.Children) for children in asset.Children: children.name = f"{namespace}:{children.name}" - asset.name = sub_node_name + children.pos = transform_data[ + f"{children.name}.transform"] + children.rotation = transform_data[ + f"{children.name}.rotation"] + children.scale = transform_data[ + f"{children.name}.scale"] + asset.name = sub_node_name with maintained_selection(): rt.Select(node) From b490b2741f75d51d1dc4f6ec6fbbab238d11fa0c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 11:56:01 +0800 Subject: [PATCH 144/429] hound --- openpype/hosts/max/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 267e75e5fe..712340c99a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -390,6 +390,7 @@ def get_namespace(container_name): namespace = rt.getUserProp(node, "namespace") return namespace, name + def object_transform_set(container_children): """A function which allows to store the transform of previous loaded object(s) From e9952b5d53421794b58b278d015412f368baf88e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Aug 2023 11:26:03 +0200 Subject: [PATCH 145/429] global: avoiding cleanup of flagged representation --- openpype/pipeline/publish/lib.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index ada12800a9..08ce8c2c9d 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -953,6 +953,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True): return file_path + def add_repre_files_for_cleanup(instance, repre): """ Explicitly mark repre files to be deleted. @@ -961,7 +962,16 @@ def add_repre_files_for_cleanup(instance, repre): """ files = repre["files"] staging_dir = repre.get("stagingDir") - if not staging_dir or instance.data.get("stagingDir_persistent"): + + # first make sure representation level is not persistent + if ( + not staging_dir + or repre.get("stagingDir_persistent") + ): + return + + # then look into instance level if it's not persistent + if instance.data.get("stagingDir_persistent"): return if isinstance(files, str): From 499b4623a30a0b4d7d900683d119394f27304540 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Aug 2023 15:41:21 +0200 Subject: [PATCH 146/429] adding abstraction of publishing related functions --- openpype/pipeline/colorspace.py | 153 ++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 9 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 731132911a..649d355f62 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -13,12 +13,17 @@ from openpype.lib import ( Logger ) from openpype.pipeline import Anatomy +from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS + log = Logger.get_logger(__name__) -class CashedData: +class CachedData: remapping = None + allowed_exts = { + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + } @contextlib.contextmanager @@ -546,15 +551,15 @@ def get_remapped_colorspace_to_native( Union[str, None]: native colorspace name defined in remapping or None """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("to_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("to_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["to_native"] = { + CachedData.remapping[host_name]["to_native"] = { rule["ocio_name"]: rule["host_native_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["to_native"].get( + return CachedData.remapping[host_name]["to_native"].get( ocio_colorspace_name) @@ -572,15 +577,15 @@ def get_remapped_colorspace_from_native( Union[str, None]: Ocio colorspace name defined in remapping or None. """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("from_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("from_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["from_native"] = { + CachedData.remapping[host_name]["from_native"] = { rule["host_native_name"]: rule["ocio_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["from_native"].get( + return CachedData.remapping[host_name]["from_native"].get( host_native_colorspace_name) @@ -601,3 +606,133 @@ def _get_imageio_settings(project_settings, host_name): imageio_host = project_settings.get(host_name, {}).get("imageio", {}) return imageio_global, imageio_host + + +def get_colorspace_settings_from_publish_context(context_data): + """Returns solved settings for the host context. + + Args: + context_data (publish.Context.data): publishing context data + + Returns: + tuple | bool: config, file rules or None + """ + if "imageioSettings" in context_data: + return context_data["imageioSettings"] + + project_name = context_data["projectName"] + host_name = context_data["hostName"] + anatomy_data = context_data["anatomyData"] + project_settings_ = context_data["project_settings"] + + config_data = get_imageio_config( + project_name, host_name, + project_settings=project_settings_, + anatomy_data=anatomy_data + ) + + # in case host color management is not enabled + if not config_data: + return None + + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings_ + ) + + # caching settings for future instance processing + context_data["imageioSettings"] = (config_data, file_rules) + + return config_data, file_rules + + +def set_colorspace_data_to_representation( + representation, context_data, + colorspace=None, + colorspace_settings=None, + log=None +): + """Sets colorspace data to representation. + + Args: + representation (dict): publishing representation + context_data (publish.Context.data): publishing context data + config_data (dict): host resolved config data + file_rules (dict): host resolved file rules data + colorspace (str, optional): colorspace name. Defaults to None. + colorspace_settings (tuple[dict, dict], optional): + Settings for config_data and file_rules. + Defaults to None. + log (logging.Logger, optional): logger instance. Defaults to None. + + Example: + ``` + { + # for other publish plugins and loaders + "colorspace": "linear", + "config": { + # for future references in case need + "path": "/abs/path/to/config.ocio", + # for other plugins within remote publish cases + "template": "{project[root]}/path/to/config.ocio" + } + } + ``` + + """ + log = log or Logger.get_logger(__name__) + + file_ext = representation["ext"] + + # check if `file_ext` in lower case is in CachedData.allowed_exts + if file_ext.lstrip(".").lower() not in CachedData.allowed_exts: + log.debug( + "Extension '{}' is not in allowed extensions.".format(file_ext) + ) + return + + if colorspace_settings is None: + colorspace_settings = get_colorspace_settings_from_publish_context( + context_data) + + # in case host color management is not enabled + if not colorspace_settings: + log.warning("Host's colorspace management is disabled.") + return + + # unpack colorspace settings + config_data, file_rules = colorspace_settings + + if not config_data: + # warn in case no colorspace path was defined + log.warning("No colorspace management was defined") + return + + log.debug("Config data is: `{}`".format(config_data)) + + project_name = context_data["projectName"] + host_name = context_data["hostName"] + project_settings = context_data["project_settings"] + + # get one filename + filename = representation["files"] + if isinstance(filename, list): + filename = filename[0] + + # get matching colorspace from rules + colorspace = colorspace or get_imageio_colorspace_from_filepath( + filename, host_name, project_name, + config_data=config_data, + file_rules=file_rules, + project_settings=project_settings + ) + + # infuse data to representation + if colorspace: + colorspace_data = { + "colorspace": colorspace, + "config": config_data + } + + # update data key + representation["colorspaceData"] = colorspace_data From 8260eb36bdd3e1d4cb8590f59853099b11dd75a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Aug 2023 16:39:12 +0200 Subject: [PATCH 147/429] implementing abstarctions from colorspace --- openpype/pipeline/publish/publish_plugins.py | 106 ++----------------- 1 file changed, 10 insertions(+), 96 deletions(-) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index ba3be6397e..17ede069cb 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -1,6 +1,5 @@ import inspect from abc import ABCMeta -from pprint import pformat import pyblish.api from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS @@ -14,9 +13,8 @@ from .lib import ( ) from openpype.pipeline.colorspace import ( - get_imageio_colorspace_from_filepath, - get_imageio_config, - get_imageio_file_rules + get_colorspace_settings_from_publish_context, + set_colorspace_data_to_representation ) @@ -306,12 +304,8 @@ class ColormanagedPyblishPluginMixin(object): matching colorspace from rules. Finally, it infuses this data into the representation. """ - allowed_ext = set( - ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) - ) - @staticmethod - def get_colorspace_settings(context): + def get_colorspace_settings(self, context): """Returns solved settings for the host context. Args: @@ -320,33 +314,7 @@ class ColormanagedPyblishPluginMixin(object): Returns: tuple | bool: config, file rules or None """ - if "imageioSettings" in context.data: - return context.data["imageioSettings"] - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - anatomy_data = context.data["anatomyData"] - project_settings_ = context.data["project_settings"] - - config_data = get_imageio_config( - project_name, host_name, - project_settings=project_settings_, - anatomy_data=anatomy_data - ) - - # in case host color management is not enabled - if not config_data: - return None - - file_rules = get_imageio_file_rules( - project_name, host_name, - project_settings=project_settings_ - ) - - # caching settings for future instance processing - context.data["imageioSettings"] = (config_data, file_rules) - - return config_data, file_rules + return get_colorspace_settings_from_publish_context(context.data) def set_representation_colorspace( self, representation, context, @@ -380,64 +348,10 @@ class ColormanagedPyblishPluginMixin(object): ``` """ - ext = representation["ext"] - # check extension - self.log.debug("__ ext: `{}`".format(ext)) - - # check if ext in lower case is in self.allowed_ext - if ext.lstrip(".").lower() not in self.allowed_ext: - self.log.debug( - "Extension '{}' is not in allowed extensions.".format(ext) - ) - return - - if colorspace_settings is None: - colorspace_settings = self.get_colorspace_settings(context) - - # in case host color management is not enabled - if not colorspace_settings: - self.log.warning("Host's colorspace management is disabled.") - return - - # unpack colorspace settings - config_data, file_rules = colorspace_settings - - if not config_data: - # warn in case no colorspace path was defined - self.log.warning("No colorspace management was defined") - return - - self.log.debug("Config data is: `{}`".format(config_data)) - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - project_settings = context.data["project_settings"] - - # get one filename - filename = representation["files"] - if isinstance(filename, list): - filename = filename[0] - - self.log.debug("__ filename: `{}`".format(filename)) - - # get matching colorspace from rules - colorspace = colorspace or get_imageio_colorspace_from_filepath( - filename, host_name, project_name, - config_data=config_data, - file_rules=file_rules, - project_settings=project_settings + # using cached settings if available + set_colorspace_data_to_representation( + representation, context.data, + colorspace, + colorspace_settings, + log=self.log ) - self.log.debug("__ colorspace: `{}`".format(colorspace)) - - # infuse data to representation - if colorspace: - colorspace_data = { - "colorspace": colorspace, - "config": config_data - } - - # update data key - representation["colorspaceData"] = colorspace_data - - self.log.debug("__ colorspace_data: `{}`".format( - pformat(colorspace_data))) From 1a61eb0c3e711cfcf856f7f1ef937949161e89f4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 21:29:34 +0800 Subject: [PATCH 148/429] Libor's comment on Container namespace issue and not support rotation --- openpype/hosts/max/api/lib.py | 2 -- openpype/hosts/max/plugins/load/load_camera_fbx.py | 8 ++++---- openpype/hosts/max/plugins/load/load_max_scene.py | 7 +++---- openpype/hosts/max/plugins/load/load_model.py | 3 ++- openpype/hosts/max/plugins/load/load_model_fbx.py | 8 ++++---- openpype/hosts/max/plugins/load/load_model_obj.py | 7 +++---- openpype/hosts/max/plugins/load/load_model_usd.py | 7 +++---- openpype/hosts/max/plugins/load/load_pointcache.py | 3 ++- openpype/hosts/max/plugins/load/load_pointcloud.py | 6 ++++-- openpype/hosts/max/plugins/load/load_redshift_proxy.py | 6 ++++-- 10 files changed, 29 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 712340c99a..034307e72a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -407,6 +407,4 @@ def object_transform_set(container_children): transform_set[name] = node.pos name = f"{node.name}.scale" transform_set[name] = node.scale - name = f"{node.name}.rotation" - transform_set[name] = node.rotation return transform_set diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index acd77ad686..f040115417 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -22,6 +22,7 @@ class FbxLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -41,7 +42,8 @@ class FbxLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.container(name=f"{namespace}:{name}") + container = rt.container( + name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) @@ -60,7 +62,7 @@ class FbxLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) transform_data = object_transform_set(inst_container.Children) @@ -82,8 +84,6 @@ class FbxLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" fbx_object.pos = transform_data[ f"{fbx_object.name}.transform"] - fbx_object.rotation = transform_data[ - f"{fbx_object.name}.rotation"] fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 3d524e261f..98e9be96e1 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -24,6 +24,7 @@ class MaxSceneLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -41,7 +42,7 @@ class MaxSceneLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container_name = f"{namespace}:{name}" + container_name = f"{namespace}:{name}_{self.postfix}" container = rt.Container(name=container_name) import_custom_attribute_data(container, max_objects) max_container.append(container) @@ -60,7 +61,7 @@ class MaxSceneLoader(load.LoaderPlugin): node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_container_name = f"{namespace}:{name}" + sub_container_name = f"{namespace}:{name}_{self.postfix}" # delete the old container with attribute # delete old duplicate rt.Select(node.Children) @@ -82,8 +83,6 @@ class MaxSceneLoader(load.LoaderPlugin): max_obj.name = f"{namespace}:{obj_name}" max_obj.pos = transform_data[ f"{max_obj.name}.transform"] - max_obj.rotation = transform_data[ - f"{max_obj.name}.rotation"] max_obj.scale = transform_data[ f"{max_obj.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index acc2a4032b..c5a73b4327 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -20,6 +20,7 @@ class ModelAbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -61,7 +62,7 @@ class ModelAbcLoader(load.LoaderPlugin): for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}" + abc_container_name = f"{namespace}:{name}_{self.postfix}" abc_container.name = abc_container_name return containerise( diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index fcac72dae1..56c8768675 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -21,6 +21,7 @@ class FbxModelLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -36,7 +37,8 @@ class FbxModelLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.container(name=f"{namespace}:{name}") + container = rt.container( + name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) @@ -55,7 +57,7 @@ class FbxModelLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) transform_data = object_transform_set(inst_container.Children) @@ -76,8 +78,6 @@ class FbxModelLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" fbx_object.pos = transform_data[ f"{fbx_object.name}.transform"] - fbx_object.rotation = transform_data[ - f"{fbx_object.name}.rotation"] fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 04a0ac1679..314889e6ec 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -24,6 +24,7 @@ class ObjLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -38,7 +39,7 @@ class ObjLoader(load.LoaderPlugin): suffix="_", ) # create "missing" container for obj import - container = rt.Container(name=f"{namespace}:{name}") + container = rt.Container(name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) # get current selection @@ -56,7 +57,7 @@ class ObjLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) transform_data = object_transform_set(inst_container.Children) @@ -73,8 +74,6 @@ class ObjLoader(load.LoaderPlugin): selection.name = f"{namespace}:{selection.name}" selection.pos = transform_data[ f"{selection.name}.transform"] - selection.rotation = transform_data[ - f"{selection.name}.rotation"] selection.scale = transform_data[ f"{selection.name}.scale"] with maintained_selection(): diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 14f339f039..f35d8e6327 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -23,6 +23,7 @@ class ModelUSDLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -47,7 +48,7 @@ class ModelUSDLoader(load.LoaderPlugin): for usd_asset in asset.Children: usd_asset.name = f"{namespace}:{usd_asset.name}" - asset_name = f"{namespace}:{name}" + asset_name = f"{namespace}:{name}_{self.postfix}" asset.name = asset_name # need to get the correct container after renamed asset = rt.GetNodeByName(asset_name) @@ -64,7 +65,7 @@ class ModelUSDLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" transform_data = None for n in node.Children: rt.Select(n.Children) @@ -91,8 +92,6 @@ class ModelUSDLoader(load.LoaderPlugin): children.name = f"{namespace}:{children.name}" children.pos = transform_data[ f"{children.name}.transform"] - children.rotation = transform_data[ - f"{children.name}.rotation"] children.scale = transform_data[ f"{children.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 64bf7ddac0..070dea88d4 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -24,6 +24,7 @@ class AbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -68,7 +69,7 @@ class AbcLoader(load.LoaderPlugin): for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}" + abc_container_name = f"{namespace}:{name}_{self.postfix}" abc_container.name = abc_container_name return containerise( diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 87b7fce292..c4c4cfbc6c 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -20,6 +20,7 @@ class PointCloudLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" @@ -33,7 +34,8 @@ class PointCloudLoader(load.LoaderPlugin): name + "_", suffix="_", ) - prt_container = rt.Container(name=f"{namespace}:{name}") + prt_container = rt.Container( + name=f"{namespace}:{name}_{self.postfix}") import_custom_attribute_data(prt_container, [obj]) obj.Parent = prt_container obj.name = f"{namespace}:{obj.name}" @@ -49,7 +51,7 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) update_custom_attribute_data( inst_container, inst_container.Children) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index b240714314..f7dd95962b 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -25,6 +25,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -41,7 +42,8 @@ class RedshiftProxyLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.Container(name=f"{namespace}:{name}") + container = rt.Container( + name=f"{namespace}:{name}_{self.postfix}") rs_proxy.Parent = container rs_proxy.name = f"{namespace}:{rs_proxy.name}" import_custom_attribute_data(container, [rs_proxy]) @@ -55,7 +57,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): path = get_representation_path(representation) namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) update_custom_attribute_data( From e1f2a77089f406dfa7ebb8bfc22102155f1a3f1b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 25 Aug 2023 22:36:38 +0300 Subject: [PATCH 149/429] Introduce houdini unreal static mesh --- .../create/create_unreal_staticmesh.py | 36 +++-- .../houdini/plugins/publish/extract_fbx.py | 2 +- .../publish/validate_fbx_hierarchy_path.py | 14 +- .../publish/validate_mesh_is_static.py | 133 ++++++++++++++++++ .../defaults/project_settings/houdini.json | 2 +- .../schemas/schema_houdini_create.json | 2 +- .../server/settings/publish_plugins.py | 8 +- 7 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 2a284f7979..479392f231 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- -"""Creator plugin for creating fbx. +"""Creator plugin for creating Unreal Static Meshes. -It was made to pratice publish process. +Unreal Static Meshes will be published as FBX. Filmbox by default expects an ObjNode however, we set the sop node explictly to eleminate any confusion. -This creator by default will select +This will make Filmbox to ignore any object transformations! + +get_obj_output selects the output sop with mimimum idx or the node with render flag isntead. @@ -15,13 +17,13 @@ This plugin is part of publish process guide. """ from openpype.hosts.houdini.api import plugin -from openpype.lib import EnumDef +from openpype.lib import BoolDef, EnumDef import hou -class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): - """Filmbox FBX Driver.""" +class CreateUnrealStaticMesh(plugin.HoudiniCreator): + """Unreal Static Meshes with collisions. """ # you should set identifier = "io.openpype.creators.houdini.unrealstaticmesh" @@ -41,7 +43,7 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): instance_data.update({"node_type": "filmboxfbx"}) # create instance (calls HoudiniCreator.create()) - instance = super(HouCreateUnrealStaticMesh, self).create( + instance = super(CreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) @@ -78,8 +80,15 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): }, default=0, label="Vertex Cache Format") + convert_units = BoolDef("convertunits", + tooltip="When on, the FBX is converted" + "from the current Houdini " + "system units to the native " + "FBX unit of centimeters.", + default=False, + label="Convert Units") - return attrs + [vcformat] + return attrs + [vcformat, convert_units] def get_parms(self, subset_name, pre_create_data): """Get parameters values for this specific node.""" @@ -94,15 +103,18 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): # 3. get Vertex Cache Format vcformat = pre_create_data.get("vcformat") - # 4. Valid Frame Range - # It should publish the current frame. - trange = 0 + # 4. get convert_units + convertunits = pre_create_data.get("convertunits") + + # 5. get Valid Frame Range + trange = 1 # parms dictionary parms = { "startnode": selection, "sopoutput": output_path, "vcformat": vcformat, + "convertunits": convertunits, "trange": trange } @@ -166,7 +178,7 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): def get_obj_output(self, obj_node): """Find output node with the smallest 'outputidx' - or return tje node with the render flag instead. + or return the node with the render flag instead. """ outputs = obj_node.subnetOutputs() diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 8e45a554c0..c0e84c00c8 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -16,7 +16,7 @@ from openpype.hosts.houdini.api.lib import render_rop import hou -class ExtractRedshiftProxy(publish.Extractor): +class ExtractFBX(publish.Extractor): label = "Extract FBX" families = ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 01cb01f497..be73ccd223 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -65,8 +65,8 @@ class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, actions = [SelectInvalidAction, AddDefaultPathAction, SelectROPAction] - # 'OptionalPyblishPluginMixin' where logic for 'optional' is implemented. - # It requires updating project settings + # 'OptionalPyblishPluginMixin' adds the functionality to + # enable/disable plugins, It requires adding new settings. optional = True # overrides InstancePlugin.process() @@ -116,13 +116,9 @@ class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, cls.log.debug("Checking for attribute: %s", path_attr) - # Get frame - frame = hou.intFrame() - trange = rop_node.parm("trange").eval() - if trange: - frame = int(hou.playbar.frameRange()[0]) - - frame = instance.data.get("frameStart", frame) + # Use current frame if "frameStart" doesn't exist + # This only happens when ""trange" is 0 + frame = instance.data.get("frameStart", hou.intFrame()) # Get Geo at that frame geo = output_node.geometryAtFrame(frame) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py new file mode 100644 index 0000000000..fa6442d0d4 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""Validate mesh is static. + +This plugin is part of publish process guide. +""" + +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou + + +# Each validation can have a single repair action +# which calls the repair method +class FreezeTimeAction(RepairAction): + label = "Freeze Time" + icon = "ei.pause-alt" + + +class ValidateMeshIsStatic(pyblish.api.InstancePlugin): + """Validate mesh is static. + + It checks if node is time dependant. + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate mesh is static" + + # Usually you will use this value as default + order = ValidateContentsOrder + 0.1 + + # Validation can have as many actions as you want + # all of these actions are defined in a seperate place + # unlike the repair action + actions = [FreezeTimeAction, SelectInvalidAction, + SelectROPAction] + + # overrides InstancePlugin.process() + 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(nodes), + title=self.label + ) + + # This method was named get_invalid as a convention + # it's also used by SelectInvalidAction to select + # the returned nodes + @classmethod + def get_invalid(cls, instance): + + output_node = instance.data.get("output_node") + if output_node.isTimeDependent(): + cls.log.info("Mesh is not static!") + return [output_node] + + + + # what repair action expects to find and call + @classmethod + def repair(cls, instance): + """Adds a time shift node. + + It should kill time dependency. + """ + + rop_node = hou.node(instance.data["instance_node"]) + # I'm doing so because an artist may change output node + # before clicking the button. + output_node = rop_node.parm("startnode").evalAsNode() + + if not output_node: + cls.log.debug( + "Action isn't performed, invalid SOP Path on %s", + rop_node + ) + return + + # This check to prevent the action from running multiple times. + # git_invalid only returns [output_node] when + # path attribute is the problem + if cls.get_invalid(instance) != [output_node]: + return + + + + time_shift = output_node.parent().createNode("timeshift", + "freeze_time") + time_shift.parm("frame").deleteAllKeyframes() + + frame = instance.data.get("frameStart", hou.intFrame()) + time_shift.parm("frame").set(frame) + + cls.log.debug( + "'%s' was created. It will kill time dependency." + , time_shift + ) + + time_shift.setGenericFlag(hou.nodeFlag.DisplayComment, True) + time_shift.setComment( + 'This node was created automatically by ' + '"Freeze Time" Action' + '\nFeel free to modify or replace it.' + ) + + if output_node.type().name() in ["null", "output"]: + # Connect before + time_shift.setFirstInput(output_node.input(0)) + time_shift.moveToGoodPosition() + output_node.setFirstInput(time_shift) + output_node.moveToGoodPosition() + else: + # Connect after + time_shift.setFirstInput(output_node) + rop_node.parm("startnode").set(time_shift.path()) + time_shift.moveToGoodPosition() + + cls.log.debug( + "SOP path on '%s' updated to new output node '%s'", + rop_node, time_shift + ) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index e19e71de17..c39eb717fd 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -19,7 +19,7 @@ ], "ext": ".ass" }, - "HouCreateUnrealStaticMesh": { + "CreateUnrealStaticMesh": { "enabled": true, "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 3d55bd834f..b19761df91 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 @@ -42,7 +42,7 @@ { "type": "dict", "collapsible": true, - "key": "HouCreateUnrealStaticMesh", + "key": "CreateUnrealStaticMesh", "label": "Create Unreal - Static Mesh", "checkbox_key": "enabled", "children": [ diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index aaa1d0ba1d..1e5cd7f551 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class CreateArnoldAssModel(BaseSettingsModel): ext: str = Field(Title="Extension") -class HouCreateUnrealStaticMeshModel(BaseSettingsModel): +class CreateUnrealStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") default_variants: list[str] = Field( default_factory=list, @@ -39,8 +39,8 @@ class CreatePluginsModel(BaseSettingsModel): default_factory=CreateArnoldAssModel, title="Create Alembic Camera") # "-" is not compatible in the new model - HouCreateUnrealStaticMesh: HouCreateUnrealStaticMeshModel = Field( - default_factory=HouCreateUnrealStaticMeshModel, + CreateUnrealStaticMesh: CreateUnrealStaticMeshModel = Field( + default_factory=CreateUnrealStaticMeshModel, title="Create Unreal_Static Mesh" ) CreateAlembicCamera: CreatorModel = Field( @@ -81,7 +81,7 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"], "ext": ".ass" }, - "HouCreateUnrealStaticMesh": { + "CreateUnrealStaticMesh": { "enabled": True, "default_variants": [ "Main" From 3d658bb3f23aa9bf50f0a46a62f810b01bc06139 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 25 Aug 2023 22:39:10 +0300 Subject: [PATCH 150/429] resolve hound conversations --- .../houdini/plugins/publish/validate_mesh_is_static.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index fa6442d0d4..ac80fda537 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -66,8 +66,6 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin): cls.log.info("Mesh is not static!") return [output_node] - - # what repair action expects to find and call @classmethod def repair(cls, instance): @@ -94,8 +92,6 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin): if cls.get_invalid(instance) != [output_node]: return - - time_shift = output_node.parent().createNode("timeshift", "freeze_time") time_shift.parm("frame").deleteAllKeyframes() @@ -104,8 +100,8 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin): time_shift.parm("frame").set(frame) cls.log.debug( - "'%s' was created. It will kill time dependency." - , time_shift + "'%s' was created. It will kill time dependency.", + time_shift ) time_shift.setGenericFlag(hou.nodeFlag.DisplayComment, True) From c610a850b9e12023b2a6e5f92769b2a971b7b2d1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Sat, 26 Aug 2023 01:23:20 +0300 Subject: [PATCH 151/429] dynamic subset name --- .../plugins/create/create_unreal_staticmesh.py | 14 ++++++++++++++ .../settings/defaults/project_settings/global.json | 3 ++- server_addon/core/server/settings/tools.py | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 479392f231..179c81510a 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -90,6 +90,20 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): return attrs + [vcformat, convert_units] + # Overrides BaseCreator.get_dynamic_data() + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name, instance + ): + """ + The default subset name templates for Unreal include {asset} and thus + we should pass that along as dynamic data. + """ + dynamic_data = super(CreateUnrealStaticMesh, self).get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name, instance + ) + dynamic_data["asset"] = asset_doc["name"] + return dynamic_data + def get_parms(self, subset_name, pre_create_data): """Get parameters values for this specific node.""" diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 06a595d1c5..52ac745f6d 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -429,7 +429,8 @@ "staticMesh" ], "hosts": [ - "maya" + "maya", + "houdini" ], "task_types": [], "tasks": [], diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 7befc795e4..5dbe6ab215 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -370,7 +370,8 @@ DEFAULT_TOOLS_VALUES = { "staticMesh" ], "hosts": [ - "maya" + "maya", + "houdini" ], "task_types": [], "tasks": [], From 3a43806a5b30496c44b0adb469f1b1a52355b66d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Sat, 26 Aug 2023 01:23:52 +0300 Subject: [PATCH 152/429] dynamic subset name --- server_addon/core/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From 1157971186c8b364afa03a6081246b8bca41abb9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 28 Aug 2023 18:55:49 +0800 Subject: [PATCH 153/429] ornatrix is a fur modifier so no loading model for ornatrix abc --- .../max/plugins/load/load_model_ornatrix.py | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 openpype/hosts/max/plugins/load/load_model_ornatrix.py diff --git a/openpype/hosts/max/plugins/load/load_model_ornatrix.py b/openpype/hosts/max/plugins/load/load_model_ornatrix.py deleted file mode 100644 index 07633ec55d..0000000000 --- a/openpype/hosts/max/plugins/load/load_model_ornatrix.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise -from openpype.hosts.max.api import lib - - -class OxModelAbcLoader(load.LoaderPlugin): - """Loading model with the Ornatrix Alembic loader.""" - - families = ["model"] - label = "Load Model with Ornatrix Alembic" - representations = ["abc"] - order = -10 - icon = "code-fork" - color = "orange" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - - file_path = os.path.normpath(self.filepath_from_context(context)) - scene_object_before = [obj for obj in rt.rootNode.Children] - rt.AlembicImport.ImportToRoot = True - rt.AlembicImport.CustomAttributes = True - rt.AlembicImport.UVs = True - rt.AlembicImport.VertexColors = True - rt.importFile( - file_path, rt.name("noPrompt"), - using=rt.Ornatrix_Alembic_Importer) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - - abc_container = rt.Container(name=name) - for abc in scene_object_after: - abc.Parent = abc_container - - return containerise( - name, [abc_container], context, loader=self.__class__.__name__ - ) - - def update(self, container, representation): - from pymxs import runtime as rt - - path = get_representation_path(representation) - node_name = container["instance_node"] - instance_name, _ = os.path.splitext(node_name) - container = rt.getNodeByName(instance_name) - for children in container.Children: - rt.Delete(children) - - scene_object_before = [obj for obj in rt.rootNode.Children] - rt.AlembicImport.ImportToRoot = True - rt.AlembicImport.CustomAttributes = True - rt.AlembicImport.UVs = True - rt.AlembicImport.VertexColors = True - rt.importFile( - path, rt.name("noPrompt"), - using=rt.Ornatrix_Alembic_Importer) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - - for scene_object in scene_object_after: - scene_object.Parent = container - - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) - - def switch(self, container, representation): - self.update(container, representation) - - def remove(self, container): - from pymxs import runtime as rt - - node = rt.GetNodeByName(container["instance_node"]) - rt.Delete(node) From d491b4f18be6bdc95bca2eb08077d8da1d5c79d5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 14:28:31 +0200 Subject: [PATCH 154/429] Optimize query (use `cmds.ls` once), add Select Invalid action, improve validation report, avoid "Unknown object type" errors --- .../validate_plugin_path_attributes.py | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 78334cd01f..9f47bf7a3d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -4,6 +4,8 @@ from maya import cmds import pyblish.api +from openpype.hosts.maya.api.lib import pairwise +from openpype.hosts.maya.api.action import SelectInvalidAction from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError @@ -19,31 +21,33 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): hosts = ['maya'] families = ["workfile"] label = "Plug-in Path Attributes" + actions = [SelectInvalidAction] - def get_invalid(self, instance): + # Attributes are defined in project settings + attribute = [] + + @classmethod + def get_invalid(cls, instance): invalid = list() - # get the project setting - validate_path = ( - instance.context.data["project_settings"]["maya"]["publish"] - ) - file_attr = validate_path["ValidatePluginPathAttributes"]["attribute"] + file_attr = cls.attribute if not file_attr: return invalid - # get the nodes and file attributes - for node, attr in file_attr.items(): - # check the related nodes - targets = cmds.ls(type=node) + # Consider only valid node types to avoid "Unknown object type" warning + all_node_types = set(cmds.allNodeTypes()) + node_types = [key for key in file_attr.keys() if key in all_node_types] - for target in targets: - # get the filepath - file_attr = "{}.{}".format(target, attr) - filepath = cmds.getAttr(file_attr) + for node, node_type in pairwise(cmds.ls(type=node_types, + showType=True)): + # get the filepath + file_attr = "{}.{}".format(node, file_attr[node_type]) + filepath = cmds.getAttr(file_attr) - if filepath and not os.path.exists(filepath): - self.log.error("File {0} not exists".format(filepath)) # noqa - invalid.append(target) + if filepath and not os.path.exists(filepath): + cls.log.error("{} '{}' uses non-existing filepath: {}" + .format(node_type, node, filepath)) + invalid.append(node) return invalid @@ -51,5 +55,16 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): """Process all directories Set as Filenames in Non-Maya Nodes""" invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Non-existent Path " - "found: {0}".format(invalid)) + raise PublishValidationError( + title="Plug-in Path Attributes", + message="Non-existent filepath found on nodes: {}".format( + ", ".join(invalid) + ), + description=( + "## Plug-in nodes use invalid filepaths\n" + "The workfile contains nodes from plug-ins that use " + "filepaths which do not exist.\n\n" + "Please make sure their filepaths are correct and the " + "files exist on disk." + ) + ) From afebe088370cb3170ff3147f7258c55224654096 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 16:23:36 +0200 Subject: [PATCH 155/429] Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message --- .../plugins/publish/validate_shape_zero.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 7a7e9a0aee..c7af6a60db 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -7,6 +7,7 @@ from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, + PublishValidationError ) @@ -67,5 +68,30 @@ class ValidateShapeZero(pyblish.api.Validator): invalid = self.get_invalid(instance) if invalid: - raise ValueError("Shapes found with non-zero component tweaks: " - "{0}".format(invalid)) + raise PublishValidationError( + title="Shape Component Tweaks", + message="Shapes found with non-zero component tweaks: '{}'" + "".format(", ".join(invalid)), + description=( + "## Shapes found with component tweaks\n" + "Shapes were detected that have component tweaks on their " + "components. Please remove the component tweaks to " + "continue.\n\n" + "### Repair\n" + "The repair action will try to *freeze* the component " + "tweaks into the shapes, which is usually the correct fix " + "if the mesh has no construction history (= has its " + "history deleted)."), + detail=( + "Maya allows to store component tweaks within shape nodes " + "which are applied between its `inMesh` and `outMesh` " + "connections resulting in the output of a shape node " + "differing from the input. We usually want to avoid this " + "for published meshes (in particular for Maya scenes) as " + "it can have unintended results when using these meshes " + "as intermediate meshes since it applies positional " + "differences without being visible edits in the node " + "graph.\n\n" + "These tweaks are traditionally stored in the `.pnts` " + "attribute of shapes.") + ) From 7a513cde9a48b6185f2dbc27a267c80aea2b4f64 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 16:29:52 +0200 Subject: [PATCH 156/429] Remove Validate Instance Attributes The new publisher always updates and imprints the publish instances with the latest Attribute Definitions of the Creator, thus making this legacy validator redundant. Currently legacy instances do not work through the new publisher (they need to be converted to work correctly) and thus even for legacy workfiles this validator is redundant. --- .../publish/validate_instance_attributes.py | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 openpype/hosts/maya/plugins/publish/validate_instance_attributes.py diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py b/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py deleted file mode 100644 index f870c9f8c4..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py +++ /dev/null @@ -1,60 +0,0 @@ -from maya import cmds - -import pyblish.api -from openpype.pipeline.publish import ( - ValidateContentsOrder, PublishValidationError, RepairAction -) -from openpype.pipeline import discover_legacy_creator_plugins -from openpype.hosts.maya.api.lib import imprint - - -class ValidateInstanceAttributes(pyblish.api.InstancePlugin): - """Validate Instance Attributes. - - New attributes can be introduced as new features come in. Old instances - will need to be updated with these attributes for the documentation to make - sense, and users do not have to recreate the instances. - """ - - order = ValidateContentsOrder - hosts = ["maya"] - families = ["*"] - label = "Instance Attributes" - plugins_by_family = { - p.family: p for p in discover_legacy_creator_plugins() - } - actions = [RepairAction] - - @classmethod - def get_missing_attributes(self, instance): - plugin = self.plugins_by_family[instance.data["family"]] - subset = instance.data["subset"] - asset = instance.data["asset"] - objset = instance.data["objset"] - - missing_attributes = {} - for key, value in plugin(subset, asset).data.items(): - if not cmds.objExists("{}.{}".format(objset, key)): - missing_attributes[key] = value - - return missing_attributes - - def process(self, instance): - objset = instance.data.get("objset") - if objset is None: - self.log.debug( - "Skipping {} because no objectset found.".format(instance) - ) - return - - missing_attributes = self.get_missing_attributes(instance) - if missing_attributes: - raise PublishValidationError( - "Missing attributes on {}:\n{}".format( - objset, missing_attributes - ) - ) - - @classmethod - def repair(cls, instance): - imprint(instance.data["objset"], cls.get_missing_attributes(instance)) From e809050f15c98e965b5561249e8c7b604a3e15c9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 16:54:39 +0200 Subject: [PATCH 157/429] Re-use existing select invalid and repair actions, provide more elaborate PublishValidationError report, plus fix "optional" support by using `OptionalPyblishPluginMixin` base class. --- .../publish/validate_instance_in_context.py | 135 +++++++----------- 1 file changed, 49 insertions(+), 86 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py index b257add7e8..4ded57137c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py @@ -3,94 +3,19 @@ from __future__ import absolute_import import pyblish.api +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - ValidateContentsOrder, PublishValidationError + RepairAction, + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin ) from maya import cmds -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Select Instances" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - if instances: - self.log.info( - "Selecting invalid nodes: %s" % ", ".join( - [str(x) for x in instances] - ) - ) - self.select(instances) - else: - self.log.info("No invalid nodes found.") - self.deselect() - - def select(self, instances): - cmds.select(instances, replace=True, noExpand=True) - - def deselect(self): - cmds.select(deselect=True) - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - # Get the errored instances - failed = [] - for result in context.data["results"]: - if result["error"] is None: - continue - if result["instance"] is None: - continue - if result["instance"] in failed: - continue - if result["plugin"] != plugin: - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - self.set_attribute(instance, context_asset) - - def set_attribute(self, instance, context_asset): - cmds.setAttr( - instance.data.get("name") + ".asset", - context_asset, - type="string" - ) - - -class ValidateInstanceInContext(pyblish.api.InstancePlugin): +class ValidateInstanceInContext(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validator to check if instance asset match context asset. When working in per-shot style you always publish data in context of @@ -104,11 +29,49 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin): label = "Instance in same Context" optional = True hosts = ["maya"] - actions = [SelectInvalidInstances, RepairSelectInvalidInstances] + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] def process(self, instance): + if not self.is_active(instance.data): + return + asset = instance.data.get("asset") - context_asset = instance.context.data["assetEntity"]["name"] - msg = "{} has asset {}".format(instance.name, asset) + context_asset = self.get_context_asset(instance) if asset != context_asset: - raise PublishValidationError(msg) + raise PublishValidationError( + message=( + "Instance '{}' publishes to different asset than current " + "context: {}. Current context: {}".format( + instance.name, asset, context_asset + ) + ), + description=( + "## Publishing to a different asset\n" + "There are publish instances present which are publishing " + "into a different asset than your current context.\n\n" + "Usually this is not what you want but there can be cases " + "where you might want to publish into another asset or " + "shot. If that's the case you can disable the validation " + "on the instance to ignore it." + ) + ) + + @classmethod + def get_invalid(cls, instance): + return [instance.data["instance_node"]] + + @classmethod + def repair(cls, instance): + context_asset = cls.get_context_asset(instance) + instance_node = instance.data["instance_node"] + cmds.setAttr( + "{}.asset".format(instance_node), + context_asset, + type="string" + ) + + @staticmethod + def get_context_asset(instance): + return instance.context.data["assetEntity"]["name"] From 749e8911f5f1a8ce00ce8e3d024004d3fb13c832 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 28 Aug 2023 23:20:43 +0800 Subject: [PATCH 158/429] make sure ornatrix loader would raise runtime error if the user doesn't have the plugin installed --- .../plugins/load/load_pointcache_ornatrix.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index f783583ff1..1dce9aaa65 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -2,7 +2,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import containerise from openpype.hosts.max.api import lib - +from pymxs import runtime as rt class OxAbcLoader(load.LoaderPlugin): """Ornatrix Alembic loader.""" @@ -15,7 +15,10 @@ class OxAbcLoader(load.LoaderPlugin): color = "orange" def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt + plugin_list = get_plugins() + if "ornatrix.dlo" not in plugin_list: + raise RuntimeError("Ornatrix plugin not " + "found/installed in Max yet..") file_path = os.path.normpath(self.filepath_from_context(context)) scene_object_before = [obj for obj in rt.rootNode.Children] @@ -37,8 +40,6 @@ class OxAbcLoader(load.LoaderPlugin): ) def update(self, container, representation): - from pymxs import runtime as rt - path = get_representation_path(representation) node_name = container["instance_node"] instance_name, _ = os.path.splitext(node_name) @@ -68,7 +69,17 @@ class OxAbcLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) + + +def get_plugins() -> list: + """Get plugin list from 3ds max.""" + manager = rt.PluginManager + count = manager.pluginDllCount + plugin_info_list = [] + for p in range(1, count + 1): + plugin_info = manager.pluginDllName(p) + plugin_info_list.append(plugin_info) + + return plugin_info_list From d92f2bac2f0c4115500d48b3223f65eb7c052e7f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 28 Aug 2023 23:21:54 +0800 Subject: [PATCH 159/429] hound --- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 1dce9aaa65..5f7e0a05f8 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -4,6 +4,7 @@ from openpype.hosts.max.api.pipeline import containerise from openpype.hosts.max.api import lib from pymxs import runtime as rt + class OxAbcLoader(load.LoaderPlugin): """Ornatrix Alembic loader.""" From 0f904cb32a0b7283cafd344f0b33fd7b95e6bda7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 17:23:19 +0200 Subject: [PATCH 160/429] adding default sequence frame data --- openpype/plugins/publish/collect_sequence_frame_data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index c200b245e9..241e7b9011 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -50,4 +50,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): return { "frameStart": repres_frames[0], "frameEnd": repres_frames[-1], + "handleStart": 0, + "handleEnd": 0, + "fps": instance.context.data["projectEntity"]["data"]["fps"] } From e6bbb0c038abb0476d66cbc1277dd170343f684b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 28 Aug 2023 22:47:18 +0300 Subject: [PATCH 161/429] add reset fbs to reset framerange --- openpype/hosts/houdini/api/lib.py | 5 +++++ openpype/hosts/houdini/api/pipeline.py | 6 ------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index b03f8c8fc1..55f4fd6197 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -474,6 +474,11 @@ def maintained_selection(): def reset_framerange(): """Set frame range to current asset""" + # Set new scene fps + fps = get_asset_fps() + print("Setting scene FPS to {}".format(int(fps))) + set_scene_fps(fps) + project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 8a26bbb504..3c325edfa7 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -25,7 +25,6 @@ from openpype.lib import ( emit_event, ) -from .lib import get_asset_fps log = logging.getLogger("openpype.hosts.houdini") @@ -385,11 +384,6 @@ def _set_context_settings(): None """ - # Set new scene fps - fps = get_asset_fps() - print("Setting scene FPS to %i" % fps) - lib.set_scene_fps(fps) - lib.reset_framerange() From ad6d03cedae5c5b7bf8ed3ea8261383045f9383d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 29 Aug 2023 17:30:15 +0800 Subject: [PATCH 162/429] fix the playbacktype --- openpype/hosts/max/plugins/load/load_pointcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index f250377cfd..3f8b241351 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -52,7 +52,7 @@ class AbcLoader(load.LoaderPlugin): for abc in rt.GetCurrentSelection(): for cam_shape in abc.Children: - cam_shape.playbackType = 2 + cam_shape.playbackType = 0 return containerise( name, [abc_container], context, loader=self.__class__.__name__ From 42d0b19b46d6c30e18b9dbaee00bba87fc7ea5fc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 29 Aug 2023 17:37:12 +0800 Subject: [PATCH 163/429] add correct plugin name --- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 5f7e0a05f8..a3c9d94f83 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -17,7 +17,7 @@ class OxAbcLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): plugin_list = get_plugins() - if "ornatrix.dlo" not in plugin_list: + if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list: raise RuntimeError("Ornatrix plugin not " "found/installed in Max yet..") From 61a8ff26f0d42577d4f19346242270ab56a75505 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Aug 2023 15:06:52 +0200 Subject: [PATCH 164/429] General: Fix Validate Publish Dir Validator (#5534) * Fix using wrong key * Update docstrings --- openpype/plugins/publish/validate_publish_dir.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/validate_publish_dir.py b/openpype/plugins/publish/validate_publish_dir.py index 2f41127548..ad5fd34434 100644 --- a/openpype/plugins/publish/validate_publish_dir.py +++ b/openpype/plugins/publish/validate_publish_dir.py @@ -7,12 +7,12 @@ from openpype.pipeline.publish import ( class ValidatePublishDir(pyblish.api.InstancePlugin): - """Validates if 'publishDir' is a project directory + """Validates if files are being published into a project directory - 'publishDir' is collected based on publish templates. In specific cases - ('source' template) source folder of items is used as a 'publishDir', this - validates if it is inside any project dir for the project. - (eg. files are not published from local folder, unaccessible for studio' + In specific cases ('source' template - in place publishing) source folder + of published items is used as a regular `publish` dir. + This validates if it is inside any project dir for the project. + (eg. files are not published from local folder, inaccessible for studio') """ @@ -44,6 +44,8 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): anatomy = instance.context.data["anatomy"] + # original_dirname must be convertable to rootless path + # in other case it is path inside of root folder for the project success, _ = anatomy.find_root_template_from_path(original_dirname) formatting_data = { @@ -56,11 +58,12 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): formatting_data=formatting_data) def _get_template_name_from_instance(self, instance): + """Find template which will be used during integration.""" project_name = instance.context.data["projectName"] host_name = instance.context.data["hostName"] anatomy_data = instance.data["anatomyData"] family = anatomy_data["family"] - family = self.family_mapping.get("family") or family + family = self.family_mapping.get(family) or family task_info = anatomy_data.get("task") or {} return get_publish_template_name( From 9f31075acd53bc378cf4c347a1802f06cb4167d1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 16:41:09 +0300 Subject: [PATCH 165/429] resolve BigRoy's conversation --- openpype/hosts/houdini/api/lib.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 55f4fd6197..28805dc015 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -22,9 +22,12 @@ log = logging.getLogger(__name__) JSON_PREFIX = "JSON:::" -def get_asset_fps(): +def get_asset_fps(asset_doc=None): """Return current asset fps.""" - return get_current_project_asset()["data"].get("fps") + + if asset_doc is None: + asset_doc = get_current_project_asset(fields=["data.fps"]) + return asset_doc["data"].get("fps") def set_id(node, unique_id, overwrite=False): @@ -472,19 +475,19 @@ def maintained_selection(): def reset_framerange(): - """Set frame range to current asset""" - - # Set new scene fps - fps = get_asset_fps() - print("Setting scene FPS to {}".format(int(fps))) - set_scene_fps(fps) + """Set frame range and FPS to current asset""" + # Get asset data project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context asset_doc = get_asset_by_name(project_name, asset_name) asset_data = asset_doc["data"] + # Get FPS + fps = get_asset_fps(asset_doc) + + # Get Start and End Frames frame_start = asset_data.get("frameStart") frame_end = asset_data.get("frameEnd") @@ -498,8 +501,12 @@ def reset_framerange(): frame_start -= int(handle_start) frame_end += int(handle_end) + # Set frame range and FPS + print("Setting scene FPS to {}".format(int(fps))) + set_scene_fps(fps) hou.playbar.setFrameRange(frame_start, frame_end) hou.playbar.setPlaybackRange(frame_start, frame_end) + print("Setting current frame to {}".format(frame_start)) hou.setFrame(frame_start) From c64e6c1fca25f096b0827d7c2418d2e67ebd1c91 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Tue, 29 Aug 2023 16:52:23 +0300 Subject: [PATCH 166/429] Update get_asset_fps Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 28805dc015..975f4e531e 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -27,7 +27,7 @@ def get_asset_fps(asset_doc=None): if asset_doc is None: asset_doc = get_current_project_asset(fields=["data.fps"]) - return asset_doc["data"].get("fps") + return asset_doc["data"]["fps"] def set_id(node, unique_id, overwrite=False): From 44df6d69a6ffbe9a73dc38849251dc95ad025528 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Tue, 29 Aug 2023 16:55:24 +0300 Subject: [PATCH 167/429] Delete print statment Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 975f4e531e..75c7ff9fee 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -506,7 +506,6 @@ def reset_framerange(): set_scene_fps(fps) hou.playbar.setFrameRange(frame_start, frame_end) hou.playbar.setPlaybackRange(frame_start, frame_end) - print("Setting current frame to {}".format(frame_start)) hou.setFrame(frame_start) From 6014cc6549d4869c81ff8cbe1acacfb30332db3a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 29 Aug 2023 16:16:31 +0200 Subject: [PATCH 168/429] Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools (#5531) * Fix optional support * Query deadline only once per url * Report both pools if both are invalid instead of only primary pool * Fix formatting in UI * Re-use existing implementation of `requests_get` * Cosmetics * Cache deadline url responses to avoid the need of request per instance * Only format error message when needed + convert to `KnownPublishError` * Allow deadline url per instance, similar to `ValidateDeadlineConnections` * Tweak grammar/readability * Fix title * Remove instance data from right side in Publish report since it's available in logs --- .../collect_deadline_server_from_instance.py | 14 +++-- .../publish/help/validate_deadline_pools.xml | 30 ++++----- .../publish/validate_deadline_connection.py | 34 ++++------ .../publish/validate_deadline_pools.py | 63 +++++++++++++------ 4 files changed, 79 insertions(+), 62 deletions(-) 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 eadfc3c83e..8a408d7f4f 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 @@ -8,6 +8,7 @@ attribute or using default server if that attribute doesn't exists. from maya import cmds import pyblish.api +from openpype.pipeline.publish import KnownPublishError class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): @@ -81,13 +82,14 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): if k in default_servers } - msg = ( - "\"{}\" server on instance is not enabled in project settings." - " Enabled project servers:\n{}".format( - instance_server, project_enabled_servers + if instance_server not in project_enabled_servers: + msg = ( + "\"{}\" server on instance is not enabled in project settings." + " Enabled project servers:\n{}".format( + instance_server, project_enabled_servers + ) ) - ) - assert instance_server in project_enabled_servers, msg + raise KnownPublishError(msg) self.log.debug("Using project approved server.") return project_enabled_servers[instance_server] diff --git a/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml b/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml index 0e7d72910e..aa21df3734 100644 --- a/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml +++ b/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml @@ -1,31 +1,31 @@ - Scene setting + Deadline Pools - ## Invalid Deadline pools found +## Invalid Deadline pools found - Configured pools don't match what is set in Deadline. +Configured pools don't match available pools in Deadline. - {invalid_value_str} +### How to repair? - ### How to repair? +If your instance had deadline pools set on creation, remove or +change them. - If your instance had deadline pools set on creation, remove or - change them. +In other cases inform admin to change them in Settings. - In other cases inform admin to change them in Settings. +Available deadline pools: + +{pools_str} - Available deadline pools {pools_str}. - ### __Detailed Info__ +### __Detailed Info__ - This error is shown when deadline pool is not on Deadline anymore. It - could happen in case of republish old workfile which was created with - previous deadline pools, - or someone changed pools on Deadline side, but didn't modify Openpype - Settings. +This error is shown when a configured pool is not available on Deadline. It +can happen when publishing old workfiles which were created with previous +deadline pools, or someone changed the available pools in Deadline, +but didn't modify Openpype Settings to match the changes. \ No newline at end of file diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index a30401e7dc..a7b300beff 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -1,8 +1,7 @@ -import os -import requests - import pyblish.api +from openpype_modules.deadline.abstract_submit_deadline import requests_get + class ValidateDeadlineConnection(pyblish.api.InstancePlugin): """Validate Deadline Web Service is running""" @@ -12,34 +11,25 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): hosts = ["maya", "nuke"] families = ["renderlayer", "render"] + # cache + responses = {} + def process(self, instance): # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") - self.log.info( - "We have deadline URL on instance {}".format( - deadline_url)) + self.log.debug( + "We have deadline URL on instance {}".format(deadline_url) + ) assert deadline_url, "Requires Deadline Webservice URL" - # Check response - response = self._requests_get(deadline_url) + if deadline_url not in self.responses: + self.responses[deadline_url] = requests_get(deadline_url) + + response = self.responses[deadline_url] assert response.ok, "Response must be ok" assert response.text.startswith("Deadline Web Service "), ( "Web service did not respond with 'Deadline Web Service'" ) - - def _requests_get(self, *args, **kwargs): - """ Wrapper for requests, disabling SSL certificate validation if - DONT_VERIFY_SSL environment variable is found. This is useful when - Deadline or Muster server are running with self-signed certificates - and their certificate is not added to trusted certificates on - client machines. - - WARNING: disabling SSL certificate validation is defeating one line - of defense SSL is providing and it is not recommended. - """ - if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa - return requests.get(*args, **kwargs) diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 594f0ef866..949caff7d8 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -25,33 +25,58 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, "maxrender"] optional = True + # cache + pools_per_url = {} + def process(self, instance): + if not self.is_active(instance.data): + return + if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return - # get default deadline webservice url from deadline module - deadline_url = instance.context.data["defaultDeadline"] - self.log.info("deadline_url::{}".format(deadline_url)) - pools = DeadlineModule.get_deadline_pools(deadline_url, log=self.log) - self.log.info("pools::{}".format(pools)) - - formatting_data = { - "pools_str": ",".join(pools) - } + deadline_url = self.get_deadline_url(instance) + pools = self.get_pools(deadline_url) + invalid_pools = {} primary_pool = instance.data.get("primaryPool") if primary_pool and primary_pool not in pools: - msg = "Configured primary '{}' not present on Deadline".format( - instance.data["primaryPool"]) - formatting_data["invalid_value_str"] = msg - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + invalid_pools["primary"] = primary_pool secondary_pool = instance.data.get("secondaryPool") if secondary_pool and secondary_pool not in pools: - msg = "Configured secondary '{}' not present on Deadline".format( - instance.data["secondaryPool"]) - formatting_data["invalid_value_str"] = msg - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + invalid_pools["secondary"] = secondary_pool + + if invalid_pools: + message = "\n".join( + "{} pool '{}' not available on Deadline".format(key.title(), + pool) + for key, pool in invalid_pools.items() + ) + raise PublishXmlValidationError( + plugin=self, + message=message, + formatting_data={"pools_str": ", ".join(pools)} + ) + + def get_deadline_url(self, instance): + # get default deadline webservice url from deadline module + deadline_url = instance.context.data["defaultDeadline"] + if instance.data.get("deadlineUrl"): + # if custom one is set in instance, use that + deadline_url = instance.data.get("deadlineUrl") + return deadline_url + + def get_pools(self, deadline_url): + if deadline_url not in self.pools_per_url: + self.log.debug( + "Querying available pools for Deadline url: {}".format( + deadline_url) + ) + pools = DeadlineModule.get_deadline_pools(deadline_url, + log=self.log) + self.log.info("Available pools: {}".format(pools)) + self.pools_per_url[deadline_url] = pools + + return self.pools_per_url[deadline_url] From c157f74b498eb5ace1822792b5813e964ac79ebc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 29 Aug 2023 16:45:09 +0200 Subject: [PATCH 169/429] Fix double spaces in message (#5190) --- openpype/plugins/publish/validate_version.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index 2b919a3119..84d52fab73 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -25,16 +25,16 @@ class ValidateVersion(pyblish.api.InstancePlugin): # TODO: Remove full non-html version upon drop of old publisher msg = ( "Version '{0}' from instance '{1}' that you are " - " trying to publish is lower or equal to an existing version " - " in the database. Version in database: '{2}'." + "trying to publish is lower or equal to an existing version " + "in the database. Version in database: '{2}'." "Please version up your workfile to a higher version number " "than: '{2}'." ).format(version, instance.data["name"], latest_version) msg_html = ( "Version {0} from instance {1} that you are " - " trying to publish is lower or equal to an existing version " - " in the database. Version in database: {2}.

" + "trying to publish is lower or equal to an existing version " + "in the database. Version in database: {2}.

" "Please version up your workfile to a higher version number " "than: {2}." ).format(version, instance.data["name"], latest_version) From e56d3530cb7668bf92b78a61be61508b23ee89fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Aug 2023 17:31:49 +0200 Subject: [PATCH 170/429] Chore: Queued event system (#5514) * implemented queued event system * implemented basic tests --- openpype/lib/events.py | 90 +++++++++++++++++++- tests/unit/openpype/lib/test_event_system.py | 83 ++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/unit/openpype/lib/test_event_system.py diff --git a/openpype/lib/events.py b/openpype/lib/events.py index dca58fcf93..496b765a05 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -3,6 +3,7 @@ import os import re import copy import inspect +import collections import logging import weakref from uuid import uuid4 @@ -340,8 +341,8 @@ class EventSystem(object): event.emit() return event - def emit_event(self, event): - """Emit event object. + def _process_event(self, event): + """Process event topic and trigger callbacks. Args: event (Event): Prepared event with topic and data. @@ -356,6 +357,91 @@ class EventSystem(object): for callback in invalid_callbacks: self._registered_callbacks.remove(callback) + def emit_event(self, event): + """Emit event object. + + Args: + event (Event): Prepared event with topic and data. + """ + + self._process_event(event) + + +class QueuedEventSystem(EventSystem): + """Events are automatically processed in queue. + + If callback triggers another event, the event is not processed until + all callbacks of previous event are processed. + + Allows to implement custom event process loop by changing 'auto_execute'. + + Note: + This probably should be default behavior of 'EventSystem'. Changing it + now could cause problems in existing code. + + Args: + auto_execute (Optional[bool]): If 'True', events are processed + automatically. Custom loop calling 'process_next_event' + must be implemented when set to 'False'. + """ + + def __init__(self, auto_execute=True): + super(QueuedEventSystem, self).__init__() + self._event_queue = collections.deque() + self._current_event = None + self._auto_execute = auto_execute + + def __len__(self): + return self.count() + + def count(self): + """Get number of events in queue. + + Returns: + int: Number of events in queue. + """ + + return len(self._event_queue) + + def process_next_event(self): + """Process next event in queue. + + Should be used only if 'auto_execute' is set to 'False'. Only single + event is processed. + + Returns: + Union[Event, None]: Processed event. + """ + + if self._current_event is not None: + raise ValueError("An event is already in progress.") + + if not self._event_queue: + return None + event = self._event_queue.popleft() + self._current_event = event + self._process_event(event) + self._current_event = None + return event + + def emit_event(self, event): + """Emit event object. + + Args: + event (Event): Prepared event with topic and data. + """ + + if not self._auto_execute or self._current_event is not None: + self._event_queue.append(event) + return + + self._event_queue.append(event) + while self._event_queue: + event = self._event_queue.popleft() + self._current_event = event + self._process_event(event) + self._current_event = None + class GlobalEventSystem: """Event system living in global scope of process. diff --git a/tests/unit/openpype/lib/test_event_system.py b/tests/unit/openpype/lib/test_event_system.py new file mode 100644 index 0000000000..aa3f929065 --- /dev/null +++ b/tests/unit/openpype/lib/test_event_system.py @@ -0,0 +1,83 @@ +from openpype.lib.events import EventSystem, QueuedEventSystem + + +def test_default_event_system(): + output = [] + expected_output = [3, 2, 1] + event_system = EventSystem() + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + assert output == expected_output, ( + "Callbacks were not called in correct order") + + +def test_base_event_system_queue(): + output = [] + expected_output = [1, 2, 3] + event_system = QueuedEventSystem() + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + assert output == expected_output, ( + "Callbacks were not called in correct order") + + +def test_manual_event_system_queue(): + output = [] + expected_output = [1, 2, 3] + event_system = QueuedEventSystem(auto_execute=False) + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + while True: + if event_system.process_next_event() is None: + break + + assert output == expected_output, ( + "Callbacks were not called in correct order") From 04145020f6c41014697ec171d9ede8a389506dce Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Aug 2023 17:35:48 +0200 Subject: [PATCH 171/429] Tests: fix unit tests (#5533) * Changed test zip file location * Updated tests and updated Unreal plugin Unreal plugin was previously ejected into general one, which was later removed as unnecessary. In Unreal plugin were kept fixed bugs from general one (slate issue, better name pattern for clique) * Updated raised exception type --- .../publish/validate_sequence_frames.py | 20 ++++++- .../publish/test_validate_sequence_frames.py | 53 +++++-------------- tests/unit/openpype/lib/test_delivery.py | 3 +- .../sync_server/test_site_operations.py | 9 ++-- 4 files changed, 39 insertions(+), 46 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 76bb25fac3..96485d5a2d 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -1,4 +1,6 @@ import clique +import os +import re import pyblish.api @@ -21,7 +23,19 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): representations = instance.data.get("representations") for repr in representations: data = instance.data.get("assetEntity", {}).get("data", {}) - patterns = [clique.PATTERNS["frames"]] + repr_files = repr["files"] + if isinstance(repr_files, str): + continue + + ext = repr.get("ext") + if not ext: + _, ext = os.path.splitext(repr_files[0]) + elif not ext.startswith("."): + ext = ".{}".format(ext) + pattern = r"\D?(?P(?P0*)\d+){}$".format( + re.escape(ext)) + patterns = [pattern] + collections, remainder = clique.assemble( repr["files"], minimum_items=1, patterns=patterns) @@ -30,6 +44,10 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): collection = collections[0] frames = list(collection.indexes) + if instance.data.get("slate"): + # Slate is not part of the frame range + frames = frames[1:] + current_range = (frames[0], frames[-1]) required_range = (data["clipIn"], data["clipOut"]) diff --git a/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py b/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py index 17e47c9f64..f472b8052a 100644 --- a/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py +++ b/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py @@ -19,7 +19,7 @@ import logging from pyblish.api import Instance as PyblishInstance from tests.lib.testing_classes import BaseTest -from openpype.plugins.publish.validate_sequence_frames import ( +from openpype.hosts.unreal.plugins.publish.validate_sequence_frames import ( ValidateSequenceFrames ) @@ -38,7 +38,13 @@ class TestValidateSequenceFrames(BaseTest): data = { "frameStart": 1001, "frameEnd": 1002, - "representations": [] + "representations": [], + "assetEntity": { + "data": { + "clipIn": 1001, + "clipOut": 1002, + } + } } yield Instance @@ -58,6 +64,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1001 + instance.data["assetEntity"]["data"]["clipOut"] = 1001 plugin.process(instance) @@ -84,49 +91,11 @@ class TestValidateSequenceFrames(BaseTest): plugin.process(instance) - @pytest.mark.parametrize("files", - [["Main_beauty.1001.v001.exr", - "Main_beauty.1002.v001.exr"]]) - def test_validate_sequence_frames_wrong_name(self, instance, - plugin, files): - # tests for names with number inside, caused clique failure before - representations = [ - { - "ext": "exr", - "files": files, - } - ] - instance.data["representations"] = representations - - with pytest.raises(AssertionError) as excinfo: - plugin.process(instance) - assert ("Must detect single collection" in - str(excinfo.value)) - - @pytest.mark.parametrize("files", - [["Main_beauty.v001.1001.ass.gz", - "Main_beauty.v001.1002.ass.gz"]]) - def test_validate_sequence_frames_possible_wrong_name( - self, instance, plugin, files): - # currently pattern fails on extensions with dots - representations = [ - { - "files": files, - } - ] - instance.data["representations"] = representations - - with pytest.raises(AssertionError) as excinfo: - plugin.process(instance) - assert ("Must not have remainder" in - str(excinfo.value)) - @pytest.mark.parametrize("files", [["Main_beauty.v001.1001.ass.gz", "Main_beauty.v001.1002.ass.gz"]]) def test_validate_sequence_frames__correct_ext( self, instance, plugin, files): - # currently pattern fails on extensions with dots representations = [ { "ext": "ass.gz", @@ -147,6 +116,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 plugin.process(instance) @@ -160,6 +130,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 with pytest.raises(ValueError) as excinfo: plugin.process(instance) @@ -175,6 +146,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 with pytest.raises(AssertionError) as excinfo: plugin.process(instance) @@ -195,6 +167,7 @@ class TestValidateSequenceFrames(BaseTest): instance.data["slate"] = True instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 plugin.process(instance) diff --git a/tests/unit/openpype/lib/test_delivery.py b/tests/unit/openpype/lib/test_delivery.py index 04a71655e3..f1e435f3f8 100644 --- a/tests/unit/openpype/lib/test_delivery.py +++ b/tests/unit/openpype/lib/test_delivery.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Test suite for delivery functions.""" -from openpype.lib.delivery import collect_frames +from openpype.lib import collect_frames def test_collect_frames_multi_sequence(): @@ -153,4 +153,3 @@ def test_collect_frames_single_file(): print(ret) assert ret == expected, "Not matching" - diff --git a/tests/unit/openpype/modules/sync_server/test_site_operations.py b/tests/unit/openpype/modules/sync_server/test_site_operations.py index 6a861100a4..c4a83e33a6 100644 --- a/tests/unit/openpype/modules/sync_server/test_site_operations.py +++ b/tests/unit/openpype/modules/sync_server/test_site_operations.py @@ -12,16 +12,19 @@ removes temporary databases (?) """ import pytest +from bson.objectid import ObjectId from tests.lib.testing_classes import ModuleUnitTest -from bson.objectid import ObjectId + +from openpype.modules.sync_server.utils import SiteAlreadyPresentError + class TestSiteOperation(ModuleUnitTest): REPRESENTATION_ID = "60e578d0c987036c6a7b741d" - TEST_FILES = [("1eCwPljuJeOI8A3aisfOIBKKjcmIycTEt", + TEST_FILES = [("1FHE70Hi7y05LLT_1O3Y6jGxwZGXKV9zX", "test_site_operations.zip", '')] @pytest.fixture(scope="module") @@ -71,7 +74,7 @@ class TestSiteOperation(ModuleUnitTest): @pytest.mark.usefixtures("setup_sync_server_module") def test_add_site_again(self, dbcon, setup_sync_server_module): """Depends on test_add_site, must throw exception.""" - with pytest.raises(ValueError): + with pytest.raises(SiteAlreadyPresentError): setup_sync_server_module.add_site(self.TEST_PROJECT_NAME, self.REPRESENTATION_ID, site_name='test_site') From ed53ef12d5befa621965847016c375033e1b24b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Aug 2023 17:48:33 +0200 Subject: [PATCH 172/429] Chore: PowerShell script for docker build (#5535) * added powershell script to build using docker * fix empty variant * make sure build folder exists * added docker_build.ps1 to readme * Tweaked readme to include reason for docker_build.ps1 --------- Co-authored-by: Petr Kalis --- README.md | 4 ++ tools/docker_build.ps1 | 98 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tools/docker_build.ps1 diff --git a/README.md b/README.md index 6caed8061c..92f1cb62dc 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ sudo ./tools/docker_build.sh centos7 If all is successful, you'll find built OpenPype in `./build/` folder. +Docker build can be also started from Windows machine, just use `./tools/docker_build.ps1` instead of shell script. + +This could be used even for building linux build (with argument `centos7` or `debian`) + #### Manual build You will need [Python >= 3.9](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll also need [curl](https://curl.se) on systems that doesn't have one preinstalled. diff --git a/tools/docker_build.ps1 b/tools/docker_build.ps1 new file mode 100644 index 0000000000..392165288c --- /dev/null +++ b/tools/docker_build.ps1 @@ -0,0 +1,98 @@ +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$repo_root = (Get-Item $script_dir).parent.FullName + +$env:PSModulePath = $env:PSModulePath + ";$($repo_root)\tools\modules\powershell" + +function Exit-WithCode($exitcode) { + # Only exit this host process if it's a child of another PowerShell parent process... + $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId + $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name + if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } + + exit $exitcode +} + +function Restore-Cwd() { + $tmp_current_dir = Get-Location + if ("$tmp_current_dir" -ne "$current_dir") { + Write-Color -Text ">>> ", "Restoring current directory" -Color Green, Gray + Set-Location -Path $current_dir + } +} + +function Get-Container { + if (-not (Test-Path -PathType Leaf -Path "$($repo_root)\build\docker-image.id")) { + Write-Color -Text "!!! ", "Docker command failed, cannot find image id." -Color Red, Yellow + Restore-Cwd + Exit-WithCode 1 + } + $id = Get-Content "$($repo_root)\build\docker-image.id" + Write-Color -Text ">>> ", "Creating container from image id ", "[", $id, "]" -Color Green, Gray, White, Cyan, White + $cid = docker create $id bash + if ($LASTEXITCODE -ne 0) { + Write-Color -Text "!!! ", "Cannot create container." -Color Red, Yellow + Restore-Cwd + Exit-WithCode 1 + } + return $cid +} + +function Change-Cwd() { + Set-Location -Path $repo_root +} + +function New-DockerBuild { + $version_file = Get-Content -Path "$($repo_root)\openpype\version.py" + $result = [regex]::Matches($version_file, '__version__ = "(?\d+\.\d+.\d+.*)"') + $openpype_version = $result[0].Groups['version'].Value + $startTime = [int][double]::Parse((Get-Date -UFormat %s)) + Write-Color -Text ">>> ", "Building OpenPype using Docker ..." -Color Green, Gray, White + $variant = $args[0] + if ($variant.Length -eq 0) { + $dockerfile = "$($repo_root)\Dockerfile" + } else { + $dockerfile = "$( $repo_root )\Dockerfile.$variant" + } + if (-not (Test-Path -PathType Leaf -Path $dockerfile)) { + Write-Color -Text "!!! ", "Dockerfile for specifed platform ", "[", $variant, "]", "doesn't exist." -Color Red, Yellow, Cyan, White, Cyan, Yellow + Restore-Cwd + Exit-WithCode 1 + } + Write-Color -Text ">>> ", "Using Dockerfile for ", "[ ", $variant, " ]" -Color Green, Gray, White, Cyan, White + + $build_dir = "$($repo_root)\build" + if (-not(Test-Path $build_dir)) { + New-Item -ItemType Directory -Path $build_dir + } + Write-Color -Text "--- ", "Cleaning build directory ..." -Color Yellow, Gray + try { + Remove-Item -Recurse -Force "$($build_dir)\*" + } catch { + Write-Color -Text "!!! ", "Cannot clean build directory, possibly because process is using it." -Color Red, Gray + Write-Color -Text $_.Exception.Message -Color Red + Exit-WithCode 1 + } + + Write-Color -Text ">>> ", "Running Docker build ..." -Color Green, Gray, White + docker build --pull --iidfile $repo_root/build/docker-image.id --build-arg BUILD_DATE=$(Get-Date -UFormat %Y-%m-%dT%H:%M:%SZ) --build-arg VERSION=$openpype_version -t pypeclub/openpype:$openpype_version -f $dockerfile . + if ($LASTEXITCODE -ne 0) { + Write-Color -Text "!!! ", "Docker command failed.", $LASTEXITCODE -Color Red, Yellow, Red + Restore-Cwd + Exit-WithCode 1 + } + Write-Color -Text ">>> ", "Copying build from container ..." -Color Green, Gray, White + $cid = Get-Container + + docker cp "$($cid):/opt/openpype/build/exe.linux-x86_64-3.9" "$($repo_root)/build" + docker cp "$($cid):/opt/openpype/build/build.log" "$($repo_root)/build" + + $endTime = [int][double]::Parse((Get-Date -UFormat %s)) + try { + New-BurntToastNotification -AppLogo "$openpype_root/openpype/resources/icons/openpype_icon.png" -Text "OpenPype build complete!", "All done in $( $endTime - $startTime ) secs. You will find OpenPype and build log in build directory." + } catch {} + Write-Color -Text "*** ", "All done in ", $($endTime - $startTime), " secs. You will find OpenPype and build log in ", "'.\build'", " directory." -Color Green, Gray, White, Gray, White, Gray +} + +Change-Cwd +New-DockerBuild $ARGS From 65bd128d510598b00b9b51f48a7eddc9ad519abc Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Tue, 29 Aug 2023 19:03:58 +0300 Subject: [PATCH 173/429] Enhancement: Update houdini main menu (#5527) * update houdini main menu * add separator --- openpype/hosts/houdini/startup/MainMenuCommon.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 47a4653d5d..5818a117eb 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -2,7 +2,19 @@ - + + + + + + From 9ff5c071b3f7e0cc1bf7f002719565d192030e3a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 21:05:43 +0300 Subject: [PATCH 174/429] resolve conversations --- openpype/hosts/houdini/api/colorspace.py | 18 +++++++++++++- .../houdini/plugins/create/create_review.py | 24 +++---------------- .../publish/validate_review_colorspace.py | 19 ++++----------- openpype/pipeline/colorspace.py | 6 ++--- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 7047644225..5c3c605cd1 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -1,7 +1,7 @@ import attr import hou from openpype.hosts.houdini.api.lib import get_color_management_preferences - +from openpype.pipeline.colorspace import get_display_view_colorspace_name @attr.s class LayerMetadata(object): @@ -54,3 +54,19 @@ class ARenderProduct(object): ) ] return colorspace_data + + +def get_default_display_view_colorspace(): + """Get default display view colorspace. + + It's used for 'ociocolorspace' parm in OpneGL Node.""" + + data = get_color_management_preferences() + config_path = data.get("config") + display = data.get("display") + view = data.get("view") + + default_view_space = get_display_view_colorspace_name(config_path, + display, + view) + return default_view_space diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index 797116aaca..75a92e5e77 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -2,6 +2,7 @@ """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace import os import hou @@ -142,9 +143,8 @@ class CreateReview(plugin.HoudiniCreator): # to OpenColorIO instance_node.setParms({"colorcorrect": 2}) - self.log.debug("Get default view colorspace name..") - - default_view_space = self.get_default_view_space() + # Get default view space for ociocolorspace parm. + default_view_space = get_default_display_view_colorspace() instance_node.setParms( {"ociocolorspace": default_view_space} ) @@ -154,21 +154,3 @@ class CreateReview(plugin.HoudiniCreator): "the default view color space '{}'" .format(instance_node, default_view_space) ) - - return default_view_space - - def get_default_view_space(self): - """Get default view space for ociocolorspace parm.""" - - from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa - from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa - - data = get_color_management_preferences() - config_path = data.get("config") - display = data.get("display") - view = data.get("view") - - default_view_space = get_display_view_colorspace_name(config_path, - display, view) - - return default_view_space diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 5390b6b52f..09e4a489d2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -3,6 +3,7 @@ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace import os import hou @@ -38,7 +39,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: - cls.log.warning( + cls.log.debug( "Default Houdini colorspace is used, " " skipping check.." ) @@ -71,25 +72,15 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): used to set colorspace on opengl node to the default view. """ - from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa - from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa - rop_node = hou.node(instance.data["instance_node"]) - data = get_color_management_preferences() - config_path = data.get("config") - display = data.get("display") - view = data.get("view") - - cls.log.debug("Get default view colorspace name..") - - default_view_space = get_display_view_colorspace_name(config_path, - display, view) + # Get default view colorspace name + default_view_space = get_default_display_view_colorspace() rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( "'OCIO Colorspace' parm on '{}' has been set to " "the default view color space '{}'" - .formate(rop_node, default_view_space) + .format(rop_node, default_view_space) ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a0efb5e18c..37974f4a0b 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -658,6 +658,6 @@ def get_display_view_colorspace_subprocess(config_path, display, view): run_openpype_process(*args, **process_kwargs) - # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + # return default view colorspace name + with open(tmp_json_path, "r") as f: + return json.load(f) From c799ae42eab8be32051ee373bdb71176002a20b8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 21:06:36 +0300 Subject: [PATCH 175/429] add spaces --- openpype/hosts/houdini/api/colorspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 5c3c605cd1..2662a968e2 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -67,6 +67,6 @@ def get_default_display_view_colorspace(): view = data.get("view") default_view_space = get_display_view_colorspace_name(config_path, - display, - view) + display, + view) return default_view_space From 75673151a6374591067c5e8f356ee3bb93706961 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 21:18:27 +0300 Subject: [PATCH 176/429] resolve hound conversations --- openpype/hosts/houdini/plugins/create/create_review.py | 2 +- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index 75a92e5e77..c087c54f6c 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -2,7 +2,7 @@ """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 09e4a489d2..2c7420bf48 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -3,7 +3,7 @@ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou From 948687e7a236a3767b07c78eba07a13663234be0 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 30 Aug 2023 03:24:53 +0000 Subject: [PATCH 177/429] [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 6d89e1eeae..12f797228b 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.2" +__version__ = "3.16.5-nightly.3" From e426aca7213a32fcde9ca5b7e923444e29a54049 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Aug 2023 03:25:28 +0000 Subject: [PATCH 178/429] 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 d7e49de5cb..669bf391cd 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.5-nightly.3 - 3.16.5-nightly.2 - 3.16.5-nightly.1 - 3.16.4 @@ -134,7 +135,6 @@ body: - 3.14.8 - 3.14.8-nightly.4 - 3.14.8-nightly.3 - - 3.14.8-nightly.2 validations: required: true - type: dropdown From 2b951d29f23916e3354ecb6a328452dbf24d5282 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 09:50:59 +0200 Subject: [PATCH 179/429] Maya: Tweak logging for artist-facing reports in publisher --- .../maya/plugins/publish/collect_assembly.py | 3 -- .../maya/plugins/publish/collect_look.py | 29 ++++++++------- .../publish/collect_multiverse_look.py | 20 +++++----- .../maya/plugins/publish/collect_render.py | 12 +++--- .../publish/collect_render_layer_aovs.py | 6 +-- .../publish/collect_renderable_camera.py | 9 +++-- .../publish/collect_unreal_staticmesh.py | 4 +- .../maya/plugins/publish/collect_xgen.py | 2 +- .../maya/plugins/publish/collect_yeti_rig.py | 5 +-- .../publish/extract_arnold_scene_source.py | 10 ++--- .../maya/plugins/publish/extract_assembly.py | 2 +- .../plugins/publish/extract_camera_alembic.py | 4 +- .../publish/extract_camera_mayaScene.py | 8 ++-- .../hosts/maya/plugins/publish/extract_fbx.py | 8 ++-- .../maya/plugins/publish/extract_gltf.py | 7 +--- .../maya/plugins/publish/extract_gpu_cache.py | 2 +- .../publish/extract_import_reference.py | 12 +++--- .../maya/plugins/publish/extract_layout.py | 8 ++-- .../maya/plugins/publish/extract_look.py | 37 +++++++++---------- .../plugins/publish/extract_maya_scene_raw.py | 7 ++-- .../maya/plugins/publish/extract_model.py | 7 ++-- .../publish/extract_multiverse_look.py | 8 ++-- .../plugins/publish/extract_multiverse_usd.py | 13 +++---- .../publish/extract_multiverse_usd_comp.py | 10 ++--- .../publish/extract_multiverse_usd_over.py | 8 ++-- .../hosts/maya/plugins/publish/extract_obj.py | 8 ++-- .../maya/plugins/publish/extract_playblast.py | 8 ++-- .../plugins/publish/extract_pointcache.py | 4 +- .../maya/plugins/publish/extract_proxy_abc.py | 4 +- .../plugins/publish/extract_redshift_proxy.py | 6 +-- .../plugins/publish/extract_rendersetup.py | 2 +- .../hosts/maya/plugins/publish/extract_rig.py | 6 +-- .../maya/plugins/publish/extract_thumbnail.py | 6 +-- .../extract_unreal_skeletalmesh_abc.py | 10 ++--- .../extract_unreal_skeletalmesh_fbx.py | 10 ++--- .../publish/extract_unreal_staticmesh.py | 10 ++--- .../maya/plugins/publish/extract_vrayproxy.py | 6 +-- .../maya/plugins/publish/extract_vrayscene.py | 17 ++++----- .../plugins/publish/extract_workfile_xgen.py | 2 +- .../maya/plugins/publish/extract_xgen.py | 4 +- .../plugins/publish/extract_yeti_cache.py | 8 ++-- .../maya/plugins/publish/extract_yeti_rig.py | 12 +++--- .../plugins/publish/reset_xgen_attributes.py | 4 +- .../plugins/publish/submit_maya_muster.py | 8 ++-- .../plugins/publish/validate_assembly_name.py | 2 +- .../publish/validate_assembly_namespaces.py | 2 +- .../plugins/publish/validate_frame_range.py | 4 +- .../plugins/publish/validate_glsl_material.py | 14 +++---- .../publish/validate_instancer_content.py | 2 +- .../validate_instancer_frame_ranges.py | 9 +---- .../plugins/publish/validate_model_name.py | 2 +- .../publish/validate_mvlook_contents.py | 13 ++++--- .../validate_skeletalmesh_hierarchy.py | 2 +- .../validate_unreal_staticmesh_naming.py | 6 +-- .../validate_vray_distributed_rendering.py | 4 +- .../validate_yeti_renderscript_callbacks.py | 4 +- .../validate_yeti_rig_input_in_instance.py | 4 +- 57 files changed, 217 insertions(+), 227 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_assembly.py b/openpype/hosts/maya/plugins/publish/collect_assembly.py index 2aef9ab908..f64d6bee44 100644 --- a/openpype/hosts/maya/plugins/publish/collect_assembly.py +++ b/openpype/hosts/maya/plugins/publish/collect_assembly.py @@ -35,14 +35,11 @@ class CollectAssembly(pyblish.api.InstancePlugin): # Get all content from the instance instance_lookup = set(cmds.ls(instance, type="transform", long=True)) data = defaultdict(list) - self.log.info(instance_lookup) hierarchy_nodes = [] for container in containers: - self.log.info(container) root = lib.get_container_transforms(container, root=True) - self.log.info(root) if not root or root not in instance_lookup: continue diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index b3da920566..a2c3d6acbf 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -356,8 +356,9 @@ class CollectLook(pyblish.api.InstancePlugin): # Thus the data will be limited to only what we need. self.log.debug("obj_set {}".format(sets[obj_set])) if not sets[obj_set]["members"]: - self.log.info( - "Removing redundant set information: {}".format(obj_set)) + self.log.debug( + "Removing redundant set information: {}".format(obj_set) + ) sets.pop(obj_set, None) self.log.debug("Gathering attribute changes to instance members..") @@ -396,9 +397,9 @@ class CollectLook(pyblish.api.InstancePlugin): if con: materials.extend(con) - self.log.info("Found materials:\n{}".format(materials)) + self.log.debug("Found materials:\n{}".format(materials)) - self.log.info("Found the following sets:\n{}".format(look_sets)) + self.log.debug("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets # history = cmds.listHistory(look_sets) history = [] @@ -456,7 +457,7 @@ class CollectLook(pyblish.api.InstancePlugin): instance.extend(shader for shader in look_sets if shader not in instance_lookup) - self.log.info("Collected look for %s" % instance) + self.log.debug("Collected look for %s" % instance) def collect_sets(self, instance): """Collect all objectSets which are of importance for publishing @@ -593,7 +594,7 @@ class CollectLook(pyblish.api.InstancePlugin): if attribute == "fileTextureName": computed_attribute = node + ".computedFileTextureNamePattern" - self.log.info(" - file source: {}".format(source)) + self.log.debug(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) try: color_space = cmds.getAttr(color_space_attr) @@ -621,7 +622,7 @@ class CollectLook(pyblish.api.InstancePlugin): dependNode=True) ) if not source and cmds.nodeType(node) in pxr_nodes: - self.log.info("Renderman: source is empty, skipping...") + self.log.debug("Renderman: source is empty, skipping...") continue # We replace backslashes with forward slashes because V-Ray # can't handle the UDIM files with the backslashes in the @@ -630,14 +631,14 @@ class CollectLook(pyblish.api.InstancePlugin): files = get_file_node_files(node) if len(files) == 0: - self.log.error("No valid files found from node `%s`" % node) + self.log.debug("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(attribute)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.debug("collection of resource done:") + self.log.debug(" - node: {}".format(node)) + self.log.debug(" - attribute: {}".format(attribute)) + self.log.debug(" - source: {}".format(source)) + self.log.debug(" - file: {}".format(files)) + self.log.debug(" - color space: {}".format(color_space)) # Define the resource yield { diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py index 33fc7a025f..f05fb76d48 100644 --- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py @@ -268,7 +268,7 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse - self.log.info("Processing mvLook for '{}'".format(instance)) + self.log.debug("Processing mvLook for '{}'".format(instance)) nodes = set() for node in instance: @@ -287,7 +287,7 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): publishMipMap = instance.data["publishMipMap"] for node in nodes: - self.log.info("Getting resources for '{}'".format(node)) + self.log.debug("Getting resources for '{}'".format(node)) # We know what nodes need to be collected, now we need to # extract the materials overrides. @@ -380,12 +380,12 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): if len(files) == 0: self.log.error("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(fname_attrib)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.debug("collection of resource done:") + self.log.debug(" - node: {}".format(node)) + self.log.debug(" - attribute: {}".format(fname_attrib)) + self.log.debug(" - source: {}".format(source)) + self.log.debug(" - file: {}".format(files)) + self.log.debug(" - color space: {}".format(color_space)) # Define the resource resource = {"node": node, @@ -406,14 +406,14 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): extra_files = [] self.log.debug("Expecting MipMaps, going to look for them.") for fname in files: - self.log.info("Checking '{}' for mipmaps".format(fname)) + self.log.debug("Checking '{}' for mipmaps".format(fname)) if is_mipmap(fname): self.log.debug(" - file is already MipMap, skipping.") continue mipmap = get_mipmap(fname) if mipmap: - self.log.info(" mipmap found for '{}'".format(fname)) + self.log.debug(" mipmap found for '{}'".format(fname)) extra_files.append(mipmap) else: self.log.warning(" no mipmap found for '{}'".format(fname)) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index c17a8789e4..82392f67bd 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -105,7 +105,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): "family": cmds.getAttr("{}.family".format(s)), } ) - self.log.info(" -> attach render to: {}".format(s)) + self.log.debug(" -> attach render to: {}".format(s)) layer_name = layer.name() @@ -137,10 +137,10 @@ class CollectMayaRender(pyblish.api.InstancePlugin): has_cameras = any(product.camera for product in render_products) assert has_cameras, "No render cameras found." - self.log.info("multipart: {}".format( + self.log.debug("multipart: {}".format( multipart)) assert expected_files, "no file names were generated, this is a bug" - self.log.info( + self.log.debug( "expected files: {}".format( json.dumps(expected_files, indent=4, sort_keys=True) ) @@ -175,7 +175,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): publish_meta_path = os.path.dirname(full_path) aov_dict[aov_first_key] = full_paths full_exp_files = [aov_dict] - self.log.info(full_exp_files) + self.log.debug(full_exp_files) if publish_meta_path is None: raise KnownPublishError("Unable to detect any expected output " @@ -227,7 +227,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): if platform.system().lower() in ["linux", "darwin"]: common_publish_meta_path = "/" + common_publish_meta_path - self.log.info( + self.log.debug( "Publish meta path: {}".format(common_publish_meta_path)) # Get layer specific settings, might be overrides @@ -300,7 +300,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): ) if rr_settings["enabled"]: data["rrPathName"] = instance.data.get("rrPathName") - self.log.info(data["rrPathName"]) + self.log.debug(data["rrPathName"]) if self.sync_workfile_version: data["version"] = context.data["version"] diff --git a/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py b/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py index c3dc31ead9..035c531a9b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py +++ b/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py @@ -37,7 +37,7 @@ class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): # Get renderer renderer = instance.data["renderer"] - self.log.info("Renderer found: {}".format(renderer)) + self.log.debug("Renderer found: {}".format(renderer)) rp_node_types = {"vray": ["VRayRenderElement", "VRayRenderElementSet"], "arnold": ["aiAOV"], @@ -66,8 +66,8 @@ class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): result.append(render_pass) - self.log.info("Found {} render elements / AOVs for " - "'{}'".format(len(result), instance.data["subset"])) + self.log.debug("Found {} render elements / AOVs for " + "'{}'".format(len(result), instance.data["subset"])) instance.data["renderPasses"] = result diff --git a/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py b/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py index d1c3cf3b2c..4443e2e0db 100644 --- a/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py +++ b/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py @@ -21,11 +21,12 @@ class CollectRenderableCamera(pyblish.api.InstancePlugin): else: layer = instance.data["renderlayer"] - self.log.info("layer: {}".format(layer)) cameras = cmds.ls(type="camera", long=True) - renderable = [c for c in cameras if - get_attr_in_layer("%s.renderable" % c, layer)] + renderable = [cam for cam in cameras if + get_attr_in_layer("{}.renderable".format(cam), layer)] - self.log.info("Found cameras %s: %s" % (len(renderable), renderable)) + self.log.debug( + "Found renderable cameras %s: %s", len(renderable), renderable + ) instance.data["cameras"] = renderable diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 79d0856fa0..03b6c4a188 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -19,7 +19,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): instance.data["geometryMembers"] = cmds.sets( geometry_set, query=True) - self.log.info("geometry: {}".format( + self.log.debug("geometry: {}".format( pformat(instance.data.get("geometryMembers")))) collision_set = [ @@ -29,7 +29,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): instance.data["collisionMembers"] = cmds.sets( collision_set, query=True) - self.log.info("collisions: {}".format( + self.log.debug("collisions: {}".format( pformat(instance.data.get("collisionMembers")))) frame = cmds.currentTime(query=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_xgen.py b/openpype/hosts/maya/plugins/publish/collect_xgen.py index 46968f7d1a..45648e1776 100644 --- a/openpype/hosts/maya/plugins/publish/collect_xgen.py +++ b/openpype/hosts/maya/plugins/publish/collect_xgen.py @@ -67,5 +67,5 @@ class CollectXgen(pyblish.api.InstancePlugin): data["transfers"] = transfers - self.log.info(data) + self.log.debug(data) instance.data.update(data) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py index bc15edd9e0..df761cde13 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py @@ -119,7 +119,6 @@ class CollectYetiRig(pyblish.api.InstancePlugin): texture_filenames = [] if image_search_paths: - # TODO: Somehow this uses OS environment path separator, `:` vs `;` # Later on check whether this is pipeline OS cross-compatible. image_search_paths = [p for p in @@ -130,13 +129,13 @@ class CollectYetiRig(pyblish.api.InstancePlugin): # List all related textures texture_filenames = cmds.pgYetiCommand(node, listTextures=True) - self.log.info("Found %i texture(s)" % len(texture_filenames)) + self.log.debug("Found %i texture(s)" % len(texture_filenames)) # Get all reference nodes reference_nodes = cmds.pgYetiGraph(node, listNodes=True, type="reference") - self.log.info("Found %i reference node(s)" % len(reference_nodes)) + self.log.debug("Found %i reference node(s)" % len(reference_nodes)) if texture_filenames and not image_search_paths: raise ValueError("pgYetiMaya node '%s' is missing the path to the " diff --git a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py index 102f0e46a2..46cc9090bb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py @@ -100,7 +100,7 @@ class ExtractArnoldSceneSource(publish.Extractor): instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) @@ -126,7 +126,7 @@ class ExtractArnoldSceneSource(publish.Extractor): instance.data["representations"].append(representation) def _extract(self, nodes, attribute_data, kwargs): - self.log.info( + self.log.debug( "Writing {} with:\n{}".format(kwargs["filename"], kwargs) ) filenames = [] @@ -180,12 +180,12 @@ class ExtractArnoldSceneSource(publish.Extractor): with lib.attribute_values(attribute_data): with lib.maintained_selection(): - self.log.info( + self.log.debug( "Writing: {}".format(duplicate_nodes) ) cmds.select(duplicate_nodes, noExpand=True) - self.log.info( + self.log.debug( "Extracting ass sequence with: {}".format(kwargs) ) @@ -194,6 +194,6 @@ class ExtractArnoldSceneSource(publish.Extractor): for file in exported_files: filenames.append(os.path.split(file)[1]) - self.log.info("Exported: {}".format(filenames)) + self.log.debug("Exported: {}".format(filenames)) return filenames, nodes_by_id diff --git a/openpype/hosts/maya/plugins/publish/extract_assembly.py b/openpype/hosts/maya/plugins/publish/extract_assembly.py index 9b2978d192..86ffdcef24 100644 --- a/openpype/hosts/maya/plugins/publish/extract_assembly.py +++ b/openpype/hosts/maya/plugins/publish/extract_assembly.py @@ -27,7 +27,7 @@ class ExtractAssembly(publish.Extractor): json_filename = "{}.json".format(instance.name) json_path = os.path.join(staging_dir, json_filename) - self.log.info("Dumping scene data for debugging ..") + self.log.debug("Dumping scene data for debugging ..") with open(json_path, "w") as filepath: json.dump(instance.data["scenedata"], filepath, ensure_ascii=False) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index aa445a0387..4ec1399df4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -94,7 +94,7 @@ class ExtractCameraAlembic(publish.Extractor): "Attributes to bake must be specified as a list" ) for attr in self.bake_attributes: - self.log.info("Adding {} attribute".format(attr)) + self.log.debug("Adding {} attribute".format(attr)) job_str += " -attr {0}".format(attr) with lib.evaluation("off"): @@ -112,5 +112,5 @@ class ExtractCameraAlembic(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 30e6b89f2f..a50a8f0dfa 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -111,7 +111,7 @@ class ExtractCameraMayaScene(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -151,7 +151,7 @@ class ExtractCameraMayaScene(publish.Extractor): with lib.evaluation("off"): with lib.suspended_refresh(): if bake_to_worldspace: - self.log.info( + self.log.debug( "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( transform, @@ -186,7 +186,7 @@ class ExtractCameraMayaScene(publish.Extractor): unlock(plug) cmds.setAttr(plug, value) - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") cmds.select(cmds.ls(members, dag=True, shapes=True, long=True), noExpand=True) cmds.file(path, @@ -217,5 +217,5 @@ class ExtractCameraMayaScene(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 9af3acef65..4f7eaf57bf 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -33,11 +33,11 @@ class ExtractFBX(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) + self.log.debug("Extracting FBX to: {0}".format(path)) members = instance.data["setMembers"] - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) @@ -58,4 +58,4 @@ class ExtractFBX(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gltf.py b/openpype/hosts/maya/plugins/publish/extract_gltf.py index ac258ffb3d..6d72d28525 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gltf.py +++ b/openpype/hosts/maya/plugins/publish/extract_gltf.py @@ -20,14 +20,10 @@ class ExtractGLB(publish.Extractor): filename = "{0}.glb".format(instance.name) path = os.path.join(staging_dir, filename) - self.log.info("Extracting GLB to: {}".format(path)) - cmds.loadPlugin("maya2glTF", quiet=True) nodes = instance[:] - self.log.info("Instance: {0}".format(nodes)) - start_frame = instance.data('frameStart') or \ int(cmds.playbackOptions(query=True, animationStartTime=True))# noqa @@ -48,6 +44,7 @@ class ExtractGLB(publish.Extractor): "vno": True # visibleNodeOnly } + self.log.debug("Extracting GLB to: {}".format(path)) with lib.maintained_selection(): cmds.select(nodes, hi=True, noExpand=True) extract_gltf(staging_dir, @@ -65,4 +62,4 @@ class ExtractGLB(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract GLB successful to: {0}".format(path)) + self.log.debug("Extract GLB successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py b/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py index 422f5ad019..16436c6fe4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py @@ -60,6 +60,6 @@ class ExtractGPUCache(publish.Extractor): instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) diff --git a/openpype/hosts/maya/plugins/publish/extract_import_reference.py b/openpype/hosts/maya/plugins/publish/extract_import_reference.py index 8bb82be9b6..9d2ff1a3eb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_import_reference.py +++ b/openpype/hosts/maya/plugins/publish/extract_import_reference.py @@ -46,7 +46,7 @@ class ExtractImportReference(publish.Extractor, for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break @@ -69,7 +69,7 @@ class ExtractImportReference(publish.Extractor, reference_path = os.path.join(dir_path, ref_scene_name) tmp_path = os.path.dirname(current_name) + "/" + ref_scene_name - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") # This generates script for mayapy to take care of reference # importing outside current session. It is passing current scene @@ -111,7 +111,7 @@ print("*** Done") # process until handles are closed by context manager. with tempfile.TemporaryDirectory() as tmp_dir_name: tmp_script_path = os.path.join(tmp_dir_name, "import_ref.py") - self.log.info("Using script file: {}".format(tmp_script_path)) + self.log.debug("Using script file: {}".format(tmp_script_path)) with open(tmp_script_path, "wt") as tmp: tmp.write(script) @@ -149,9 +149,9 @@ print("*** Done") "stagingDir": os.path.dirname(current_name), "outputName": "imported" } - self.log.info("%s" % ref_representation) + self.log.debug(ref_representation) instance.data["representations"].append(ref_representation) - self.log.info("Extracted instance '%s' to : '%s'" % (ref_scene_name, - reference_path)) + self.log.debug("Extracted instance '%s' to : '%s'" % (ref_scene_name, + reference_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_layout.py b/openpype/hosts/maya/plugins/publish/extract_layout.py index bf5b4fc0e7..75920b44a2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_layout.py +++ b/openpype/hosts/maya/plugins/publish/extract_layout.py @@ -23,7 +23,7 @@ class ExtractLayout(publish.Extractor): stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] @@ -64,7 +64,7 @@ class ExtractLayout(publish.Extractor): fields=["parent", "context.family"] ) - self.log.info(representation) + self.log.debug(representation) version_id = representation.get("parent") family = representation.get("context").get("family") @@ -159,5 +159,5 @@ class ExtractLayout(publish.Extractor): } instance.data["representations"].append(json_representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, json_representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, json_representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index b13568c781..3506027a1f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -307,7 +307,7 @@ class MakeTX(TextureProcessor): render_colorspace = color_management["rendering_space"] - self.log.info("tx: converting colorspace {0} " + self.log.debug("tx: converting colorspace {0} " "-> {1}".format(colorspace, render_colorspace)) args.extend(["--colorconvert", colorspace, render_colorspace]) @@ -331,7 +331,7 @@ class MakeTX(TextureProcessor): if not os.path.exists(resources_dir): os.makedirs(resources_dir) - self.log.info("Generating .tx file for %s .." % source) + self.log.debug("Generating .tx file for %s .." % source) subprocess_args = maketx_args + [ "-v", # verbose @@ -421,7 +421,7 @@ class ExtractLook(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -453,7 +453,7 @@ class ExtractLook(publish.Extractor): relationships = lookdata["relationships"] sets = list(relationships.keys()) if not sets: - self.log.info("No sets found for the look") + self.log.debug("No sets found for the look") return # Specify texture processing executables to activate @@ -485,7 +485,7 @@ class ExtractLook(publish.Extractor): remap = results["attrRemap"] # Extract in correct render layer - self.log.info("Extracting look maya scene file: {}".format(maya_path)) + self.log.debug("Extracting look maya scene file: {}".format(maya_path)) layer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(layer): # TODO: Ensure membership edits don't become renderlayer overrides @@ -511,12 +511,12 @@ class ExtractLook(publish.Extractor): ) # Write the JSON data - self.log.info("Extract json..") data = { "attributes": lookdata["attributes"], "relationships": relationships } + self.log.debug("Extracting json file: {}".format(json_path)) with open(json_path, "w") as f: json.dump(data, f) @@ -557,8 +557,8 @@ class ExtractLook(publish.Extractor): # Source hash for the textures instance.data["sourceHashes"] = hashes - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - maya_path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + maya_path)) def _set_resource_result_colorspace(self, resource, colorspace): """Update resource resulting colorspace after texture processing""" @@ -589,14 +589,13 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary fix to NOT create hardlinks on windows machines - if platform.system().lower() == "windows": - self.log.info( + force_copy = instance.data.get("forceCopy", False) + if not force_copy and platform.system().lower() == "windows": + # Temporary fix to NOT create hardlinks on windows machines + self.log.warning( "Forcing copy instead of hardlink due to issues on Windows..." ) force_copy = True - else: - force_copy = instance.data.get("forceCopy", False) destinations_cache = {} @@ -671,11 +670,11 @@ class ExtractLook(publish.Extractor): destination = get_resource_destination_cached(source) if force_copy or texture_result.transfer_mode == COPY: transfers.append((source, destination)) - self.log.info('file will be copied {} -> {}'.format( + self.log.debug('file will be copied {} -> {}'.format( source, destination)) elif texture_result.transfer_mode == HARDLINK: hardlinks.append((source, destination)) - self.log.info('file will be hardlinked {} -> {}'.format( + self.log.debug('file will be hardlinked {} -> {}'.format( source, destination)) # Store the hashes from hash to destination to include in the @@ -707,7 +706,7 @@ class ExtractLook(publish.Extractor): color_space_attr = "{}.colorSpace".format(node) remap[color_space_attr] = resource["result_color_space"] - self.log.info("Finished remapping destinations ...") + self.log.debug("Finished remapping destinations ...") return { "fileTransfers": transfers, @@ -815,8 +814,8 @@ class ExtractLook(publish.Extractor): if not processed_result: raise RuntimeError("Texture Processor {} returned " "no result.".format(processor)) - self.log.info("Generated processed " - "texture: {}".format(processed_result.path)) + self.log.debug("Generated processed " + "texture: {}".format(processed_result.path)) # TODO: Currently all processors force copy instead of allowing # hardlinks using source hashes. This should be refactored @@ -827,7 +826,7 @@ class ExtractLook(publish.Extractor): if not force_copy: existing = self._get_existing_hashed_texture(filepath) if existing: - self.log.info("Found hash in database, preparing hardlink..") + self.log.debug("Found hash in database, preparing hardlink..") return TextureResult( path=filepath, file_hash=texture_hash, diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index d87d6c208a..ab170fe48c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -34,7 +34,7 @@ class ExtractMayaSceneRaw(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -63,7 +63,7 @@ class ExtractMayaSceneRaw(publish.Extractor): selection += self._get_loaded_containers(members) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(selection, noExpand=True) cmds.file(path, @@ -87,7 +87,8 @@ class ExtractMayaSceneRaw(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + path)) @staticmethod def _get_loaded_containers(members): diff --git a/openpype/hosts/maya/plugins/publish/extract_model.py b/openpype/hosts/maya/plugins/publish/extract_model.py index 5137dffd94..29c952ebbc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_model.py +++ b/openpype/hosts/maya/plugins/publish/extract_model.py @@ -44,7 +44,7 @@ class ExtractModel(publish.Extractor, for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -56,7 +56,7 @@ class ExtractModel(publish.Extractor, path = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") # Get only the shape contents we need in such a way that we avoid # taking along intermediateObjects @@ -102,4 +102,5 @@ class ExtractModel(publish.Extractor, } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py index 6fe7cf0d55..c2bebeaee6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py @@ -101,10 +101,10 @@ class ExtractMultiverseLook(publish.Extractor): # Parse export options options = self.default_options - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") @@ -114,7 +114,7 @@ class ExtractMultiverseLook(publish.Extractor): type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info('Collected object {}'.format(members)) + self.log.debug('Collected object {}'.format(members)) if len(members) > 1: self.log.error('More than one member: {}'.format(members)) @@ -153,5 +153,5 @@ class ExtractMultiverseLook(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 4399eacda1..17d5891e59 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -150,7 +150,6 @@ class ExtractMultiverseUsd(publish.Extractor): return options def get_default_options(self): - self.log.info("ExtractMultiverseUsd get_default_options") return self.default_options def filter_members(self, members): @@ -173,19 +172,19 @@ class ExtractMultiverseUsd(publish.Extractor): # Parse export options options = self.get_default_options() options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") - self.log.info('Collected objects: {}'.format(members)) + self.log.debug('Collected objects: {}'.format(members)) members = self.filter_members(members) if not members: self.log.error('No members!') return - self.log.info(' - filtered: {}'.format(members)) + self.log.debug(' - filtered: {}'.format(members)) import multiverse @@ -229,7 +228,7 @@ class ExtractMultiverseUsd(publish.Extractor): self.log.debug(" - {}={}".format(key, value)) setattr(asset_write_opts, key, value) - self.log.info('WriteAsset: {} / {}'.format(file_path, members)) + self.log.debug('WriteAsset: {} / {}'.format(file_path, members)) multiverse.WriteAsset(file_path, members, asset_write_opts) if "representations" not in instance.data: @@ -243,7 +242,7 @@ class ExtractMultiverseUsd(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index a62729c198..7966c4fa93 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -105,14 +105,14 @@ class ExtractMultiverseUsdComposition(publish.Extractor): # Parse export options options = self.default_options options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") - self.log.info('Collected object {}'.format(members)) + self.log.debug('Collected object {}'.format(members)) import multiverse @@ -175,5 +175,5 @@ class ExtractMultiverseUsdComposition(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( - instance.name, file_path)) + self.log.debug("Extracted instance {} to {}".format(instance.name, + file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index cf610ac6b4..e4a97db6e4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -87,10 +87,10 @@ class ExtractMultiverseUsdOverride(publish.Extractor): # Parse export options options = self.default_options - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") @@ -100,7 +100,7 @@ class ExtractMultiverseUsdOverride(publish.Extractor): type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info("Collected object {}".format(members)) + self.log.debug("Collected object {}".format(members)) # TODO: Deal with asset, composition, override with options. import multiverse @@ -153,5 +153,5 @@ class ExtractMultiverseUsdOverride(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_obj.py b/openpype/hosts/maya/plugins/publish/extract_obj.py index 518b0f0ff8..ca94130d09 100644 --- a/openpype/hosts/maya/plugins/publish/extract_obj.py +++ b/openpype/hosts/maya/plugins/publish/extract_obj.py @@ -30,7 +30,7 @@ class ExtractObj(publish.Extractor): # The export requires forward slashes because we need to # format it into a string in a mel expression - self.log.info("Extracting OBJ to: {0}".format(path)) + self.log.debug("Extracting OBJ to: {0}".format(path)) members = instance.data("setMembers") members = cmds.ls(members, @@ -39,8 +39,8 @@ class ExtractObj(publish.Extractor): type=("mesh", "nurbsCurve"), noIntermediate=True, long=True) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) if not cmds.pluginInfo('objExport', query=True, loaded=True): cmds.loadPlugin('objExport') @@ -74,4 +74,4 @@ class ExtractObj(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract OBJ successful to: {0}".format(path)) + self.log.debug("Extract OBJ successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 9580c13841..cfab239da3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -48,7 +48,7 @@ class ExtractPlayblast(publish.Extractor): self.log.debug("playblast path {}".format(path)) def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") # get scene fps fps = instance.data.get("fps") or instance.context.data.get("fps") @@ -62,7 +62,7 @@ class ExtractPlayblast(publish.Extractor): if end is None: end = cmds.playbackOptions(query=True, animationEndTime=True) - self.log.info("start: {}, end: {}".format(start, end)) + self.log.debug("start: {}, end: {}".format(start, end)) # get cameras camera = instance.data["review_camera"] @@ -119,7 +119,7 @@ class ExtractPlayblast(publish.Extractor): filename = "{0}".format(instance.name) path = os.path.join(stagingdir, filename) - self.log.info("Outputting images to %s" % path) + self.log.debug("Outputting images to %s" % path) preset["filename"] = path preset["overwrite"] = True @@ -237,7 +237,7 @@ class ExtractPlayblast(publish.Extractor): self.log.debug("collection head {}".format(filebase)) if filebase in filename: frame_collection = collection - self.log.info( + self.log.debug( "we found collection of interest {}".format( str(frame_collection))) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 9537a11ee4..5530446e3d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -109,11 +109,11 @@ class ExtractAlembic(publish.Extractor): instance.context.data["cleanupFullPaths"].append(path) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) # Extract proxy. if not instance.data.get("proxy"): - self.log.info("No proxy nodes found. Skipping proxy extraction.") + self.log.debug("No proxy nodes found. Skipping proxy extraction.") return path = path.replace(".abc", "_proxy.abc") diff --git a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py index cf6351fdca..921ee44a24 100644 --- a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py @@ -32,7 +32,7 @@ class ExtractProxyAlembic(publish.Extractor): attr_prefixes = instance.data.get("attrPrefix", "").split(";") attr_prefixes = [value for value in attr_prefixes if value.strip()] - self.log.info("Extracting Proxy Alembic..") + self.log.debug("Extracting Proxy Alembic..") dirname = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) @@ -82,7 +82,7 @@ class ExtractProxyAlembic(publish.Extractor): instance.context.data["cleanupFullPaths"].append(path) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) # remove the bounding box bbox_master = cmds.ls("bbox_grp") cmds.delete(bbox_master) diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 834b335fc5..3868270b79 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -59,7 +59,7 @@ class ExtractRedshiftProxy(publish.Extractor): # vertex_colors = instance.data.get("vertexColors", False) # Write out rs file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.file(file_path, @@ -82,5 +82,5 @@ class ExtractRedshiftProxy(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py index 5970c038a4..7e21f5282e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py +++ b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py @@ -37,5 +37,5 @@ class ExtractRenderSetup(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance '%s' to: %s" % (instance.name, json_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig.py b/openpype/hosts/maya/plugins/publish/extract_rig.py index be57b9de07..1ffc9a7dae 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig.py @@ -27,7 +27,7 @@ class ExtractRig(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using '.{}' as scene type".format(self.scene_type)) break except AttributeError: @@ -39,7 +39,7 @@ class ExtractRig(publish.Extractor): path = os.path.join(dir_path, filename) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(instance, noExpand=True) cmds.file(path, @@ -63,4 +63,4 @@ class ExtractRig(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s", instance.name, path) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 4160ac4cb2..e44204cae0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -24,7 +24,7 @@ class ExtractThumbnail(publish.Extractor): families = ["review"] def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") camera = instance.data["review_camera"] @@ -96,7 +96,7 @@ class ExtractThumbnail(publish.Extractor): filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) - self.log.info("Outputting images to %s" % path) + self.log.debug("Outputting images to %s" % path) preset["filename"] = path preset["overwrite"] = True @@ -159,7 +159,7 @@ class ExtractThumbnail(publish.Extractor): _, thumbnail = os.path.split(playblast) - self.log.info("file list {}".format(thumbnail)) + self.log.debug("file list {}".format(thumbnail)) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py index 4a797eb462..9c2f55a1ef 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py @@ -57,9 +57,9 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting ABC to: {0}".format(path)) - self.log.info("Members: {0}".format(nodes)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting ABC to: {0}".format(path)) + self.log.debug("Members: {0}".format(nodes)) + self.log.debug("Instance: {0}".format(instance[:])) options = { "step": instance.data.get("step", 1.0), @@ -74,7 +74,7 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): "worldSpace": instance.data.get("worldSpace", True) } - self.log.info("Options: {}".format(options)) + self.log.debug("Options: {}".format(options)) if int(cmds.about(version=True)) >= 2017: # Since Maya 2017 alembic supports multiple uv sets - write them. @@ -105,4 +105,4 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract ABC successful to: {0}".format(path)) + self.log.debug("Extract ABC successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py index b162ce47f7..96175a07d7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py @@ -46,9 +46,9 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(to_extract)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting FBX to: {0}".format(path)) + self.log.debug("Members: {0}".format(to_extract)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) @@ -70,7 +70,7 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): renamed_to_extract.append("|".join(node_path)) with renamed(original_parent, parent_node): - self.log.info("Extracting: {}".format(renamed_to_extract, path)) + self.log.debug("Extracting: {}".format(renamed_to_extract, path)) fbx_exporter.export(renamed_to_extract, path) if "representations" not in instance.data: @@ -84,4 +84,4 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 44f0615a27..26ab0827e4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -37,15 +37,15 @@ class ExtractUnrealStaticMesh(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting FBX to: {0}".format(path)) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) with maintained_selection(): with parent_nodes(members): - self.log.info("Un-parenting: {}".format(members)) + self.log.debug("Un-parenting: {}".format(members)) fbx_exporter.export(members, path) if "representations" not in instance.data: @@ -59,4 +59,4 @@ class ExtractUnrealStaticMesh(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index df16c6c357..21dfcfffc5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -43,7 +43,7 @@ class ExtractVRayProxy(publish.Extractor): vertex_colors = instance.data.get("vertexColors", False) # Write out vrmesh file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.vrayCreateProxy(exportType=1, @@ -68,5 +68,5 @@ class ExtractVRayProxy(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index 8442df1611..b0615149a9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -20,13 +20,13 @@ class ExtractVrayscene(publish.Extractor): def process(self, instance): """Plugin entry point.""" if instance.data.get("exportOnFarm"): - self.log.info("vrayscenes will be exported on farm.") + self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscenes is not implemented") # handle sequence if instance.data.get("vraySceneMultipleFiles"): - self.log.info("vrayscenes will be exported on farm.") + self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscene sequences not implemented yet") @@ -40,7 +40,6 @@ class ExtractVrayscene(publish.Extractor): layer_name = instance.data.get("layer") staging_dir = self.staging_dir(instance) - self.log.info("staging: {}".format(staging_dir)) template = cmds.getAttr("{}.vrscene_filename".format(node)) start_frame = instance.data.get( "frameStartHandle") if instance.data.get( @@ -56,21 +55,21 @@ class ExtractVrayscene(publish.Extractor): staging_dir, "vrayscene", *formatted_name.split("/")) # Write out vrscene file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): if "*" not in instance.data["setMembers"]: - self.log.info( + self.log.debug( "Exporting: {}".format(instance.data["setMembers"])) set_members = instance.data["setMembers"] cmds.select(set_members, noExpand=True) else: - self.log.info("Exporting all ...") + self.log.debug("Exporting all ...") set_members = cmds.ls( long=True, objectsOnly=True, geometry=True, lights=True, cameras=True) cmds.select(set_members, noExpand=True) - self.log.info("Appending layer name {}".format(layer_name)) + self.log.debug("Appending layer name {}".format(layer_name)) set_members.append(layer_name) export_in_rs_layer( @@ -93,8 +92,8 @@ class ExtractVrayscene(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) @staticmethod def format_vray_output_filename( diff --git a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py index 0d2a97bc4b..4bd01c2df2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py @@ -241,7 +241,7 @@ class ExtractWorkfileXgen(publish.Extractor): data[palette] = {attr: old_value} cmds.setAttr(node_attr, value, type="string") - self.log.info( + self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen.py b/openpype/hosts/maya/plugins/publish/extract_xgen.py index 3c9d0bd344..8409330e49 100644 --- a/openpype/hosts/maya/plugins/publish/extract_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_xgen.py @@ -77,7 +77,7 @@ class ExtractXgen(publish.Extractor): xgenm.exportPalette( instance.data["xgmPalette"].replace("|", ""), temp_xgen_path ) - self.log.info("Extracted to {}".format(temp_xgen_path)) + self.log.debug("Extracted to {}".format(temp_xgen_path)) # Import xgen onto the duplicate. with maintained_selection(): @@ -118,7 +118,7 @@ class ExtractXgen(publish.Extractor): expressions=True ) - self.log.info("Extracted to {}".format(maya_filepath)) + self.log.debug("Extracted to {}".format(maya_filepath)) if os.path.exists(temp_xgen_path): os.remove(temp_xgen_path) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py index b61f599cab..b113e02219 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py @@ -39,7 +39,7 @@ class ExtractYetiCache(publish.Extractor): else: kwargs.update({"samples": samples}) - self.log.info( + self.log.debug( "Writing out cache {} - {}".format(start_frame, end_frame)) # Start writing the files for snap shot # will be replace by the Yeti node name @@ -53,7 +53,7 @@ class ExtractYetiCache(publish.Extractor): cache_files = [x for x in os.listdir(dirname) if x.endswith(".fur")] - self.log.info("Writing metadata file") + self.log.debug("Writing metadata file") settings = instance.data["fursettings"] fursettings_path = os.path.join(dirname, "yeti.fursettings") with open(fursettings_path, "w") as fp: @@ -63,7 +63,7 @@ class ExtractYetiCache(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("cache files: {}".format(cache_files[0])) + self.log.debug("cache files: {}".format(cache_files[0])) # Workaround: We do not explicitly register these files with the # representation solely so that we can write multiple sequences @@ -87,4 +87,4 @@ class ExtractYetiCache(publish.Extractor): } ) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py index 9a46c31177..da67cb911f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py @@ -109,7 +109,7 @@ class ExtractYetiRig(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -127,7 +127,7 @@ class ExtractYetiRig(publish.Extractor): maya_path = os.path.join(dirname, "yeti_rig.{}".format(self.scene_type)) - self.log.info("Writing metadata file") + self.log.debug("Writing metadata file: {}".format(settings_path)) image_search_path = resources_dir = instance.data["resourcesDir"] @@ -147,7 +147,7 @@ class ExtractYetiRig(publish.Extractor): dst = os.path.join(image_search_path, os.path.basename(file)) instance.data['transfers'].append([src, dst]) - self.log.info("adding transfer {} -> {}". format(src, dst)) + self.log.debug("adding transfer {} -> {}". format(src, dst)) # Ensure the imageSearchPath is being remapped to the publish folder attr_value = {"%s.imageSearchPath" % n: str(image_search_path) for @@ -182,7 +182,7 @@ class ExtractYetiRig(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("rig file: {}".format(maya_path)) + self.log.debug("rig file: {}".format(maya_path)) instance.data["representations"].append( { 'name': self.scene_type, @@ -191,7 +191,7 @@ class ExtractYetiRig(publish.Extractor): 'stagingDir': dirname } ) - self.log.info("settings file: {}".format(settings_path)) + self.log.debug("settings file: {}".format(settings_path)) instance.data["representations"].append( { 'name': 'rigsettings', @@ -201,6 +201,6 @@ class ExtractYetiRig(publish.Extractor): } ) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) cmds.select(clear=True) diff --git a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py index d8e8554b68..759aa23258 100644 --- a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py +++ b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py @@ -23,7 +23,7 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): for palette, data in xgen_attributes.items(): for attr, value in data.items(): node_attr = "{}.{}".format(palette, attr) - self.log.info( + self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) cmds.setAttr(node_attr, value, type="string") @@ -32,5 +32,5 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): # Need to save the scene, cause the attribute changes above does not # mark the scene as modified so user can exit without committing the # changes. - self.log.info("Saving changes.") + self.log.debug("Saving changes.") cmds.file(save=True) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index 8e219eae85..ef58140115 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -215,9 +215,9 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): :rtype: int :raises: Exception if template ID isn't found """ - self.log.info("Trying to find template for [{}]".format(renderer)) + self.log.debug("Trying to find template for [{}]".format(renderer)) mapped = _get_template_id(renderer) - self.log.info("got id [{}]".format(mapped)) + self.log.debug("got id [{}]".format(mapped)) return self._templates.get(mapped) def _submit(self, payload): @@ -454,8 +454,8 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): self.preflight_check(instance) - self.log.info("Submitting ...") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting ...") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) response = self._submit(payload) # response = requests.post(url, json=payload) diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py index bcc40760e0..00588cd300 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py @@ -20,7 +20,7 @@ class ValidateAssemblyName(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - cls.log.info("Checking name of {}".format(instance.name)) + cls.log.debug("Checking name of {}".format(instance.name)) content_instance = instance.data.get("setMembers", None) if not content_instance: diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py index 41ef78aab4..06577f38f7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py @@ -23,7 +23,7 @@ class ValidateAssemblyNamespaces(pyblish.api.InstancePlugin): def process(self, instance): - self.log.info("Checking namespace for %s" % instance.name) + self.log.debug("Checking namespace for %s" % instance.name) if self.get_invalid(instance): raise PublishValidationError("Nested namespaces found") diff --git a/openpype/hosts/maya/plugins/publish/validate_frame_range.py b/openpype/hosts/maya/plugins/publish/validate_frame_range.py index c6184ed348..a7043b8407 100644 --- a/openpype/hosts/maya/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/maya/plugins/publish/validate_frame_range.py @@ -47,10 +47,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, context = instance.context if instance.data.get("tileRendering"): - self.log.info(( + self.log.debug( "Skipping frame range validation because " "tile rendering is enabled." - )) + ) return frame_start_handle = int(context.data.get("frameStartHandle")) diff --git a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py index 10c48da404..3b386c3def 100644 --- a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py +++ b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py @@ -75,7 +75,7 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): """ meshes = cmds.ls(instance, type="mesh", long=True) - cls.log.info("meshes: {}".format(meshes)) + cls.log.debug("meshes: {}".format(meshes)) # load the glsl shader plugin cmds.loadPlugin("glslShader", quiet=True) @@ -96,8 +96,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): cls.log.warning("ogsfx shader file " "not found in {}".format(ogsfx_path)) - cls.log.info("Find the ogsfx shader file in " - "default maya directory...") + cls.log.debug("Searching the ogsfx shader file in " + "default maya directory...") # re-direct to search the ogsfx path in maya_dir ogsfx_path = os.getenv("MAYA_APP_DIR") + ogsfx_path if not os.path.exists(ogsfx_path): @@ -130,8 +130,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): @classmethod def pbs_shader_conversion(cls, main_shader, glsl): - cls.log.info("StringrayPBS detected " - "-> Can do texture conversion") + cls.log.debug("StringrayPBS detected " + "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader @@ -168,8 +168,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): @classmethod def arnold_shader_conversion(cls, main_shader, glsl): - cls.log.info("aiStandardSurface detected " - "-> Can do texture conversion") + cls.log.debug("aiStandardSurface detected " + "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader diff --git a/openpype/hosts/maya/plugins/publish/validate_instancer_content.py b/openpype/hosts/maya/plugins/publish/validate_instancer_content.py index 2f14693ef2..236adfb03d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instancer_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_instancer_content.py @@ -21,7 +21,7 @@ class ValidateInstancerContent(pyblish.api.InstancePlugin): members = instance.data['setMembers'] export_members = instance.data['exactExportMembers'] - self.log.info("Contents {0}".format(members)) + self.log.debug("Contents {0}".format(members)) if not len(members) == len(cmds.ls(members, type="instancer")): self.log.error("Instancer can only contain instancers") diff --git a/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py b/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py index fcfcdce8b6..714c6229d6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py +++ b/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py @@ -5,8 +5,6 @@ import pyblish.api from openpype.pipeline.publish import PublishValidationError -VERBOSE = False - def is_cache_resource(resource): """Return whether resource is a cacheFile resource""" @@ -73,9 +71,6 @@ class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin): xml = all_files.pop(0) assert xml.endswith(".xml") - if VERBOSE: - cls.log.info("Checking: {0}".format(all_files)) - # Ensure all files exist (including ticks) # The remainder file paths should be the .mcx or .mcc files valdidate_files(all_files) @@ -129,8 +124,8 @@ class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin): # for the frames required by the time range. if ticks: ticks = list(sorted(ticks)) - cls.log.info("Found ticks: {0} " - "(substeps: {1})".format(ticks, len(ticks))) + cls.log.debug("Found ticks: {0} " + "(substeps: {1})".format(ticks, len(ticks))) # Check all frames except the last since we don't # require subframes after our time range. diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 6948dcf724..f4c1aa39c7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -125,7 +125,7 @@ class ValidateModelName(pyblish.api.InstancePlugin, r = re.compile(regex) for obj in filtered: - cls.log.info("testing: {}".format(obj)) + cls.log.debug("testing: {}".format(obj)) m = r.match(obj) if m is None: cls.log.error("invalid name on: {}".format(obj)) diff --git a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py index 68784a165d..ad0fcafc56 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py @@ -35,12 +35,12 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin, publishMipMap = instance.data["publishMipMap"] enforced = True if intent in self.enforced_intents: - self.log.info("This validation will be enforced: '{}'" - .format(intent)) + self.log.debug("This validation will be enforced: '{}'" + .format(intent)) else: enforced = False - self.log.info("This validation will NOT be enforced: '{}'" - .format(intent)) + self.log.debug("This validation will NOT be enforced: '{}'" + .format(intent)) if not instance[:]: raise PublishValidationError("Instance is empty") @@ -75,8 +75,9 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin, self.log.warning(msg) if invalid: - raise PublishValidationError("'{}' has invalid look " - "content".format(instance.name)) + raise PublishValidationError( + "'{}' has invalid look content".format(instance.name) + ) def valid_file(self, fname): self.log.debug("Checking validity of '{}'".format(fname)) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py index 398b6fb7bf..9084374c76 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py @@ -28,7 +28,7 @@ class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): parent.split("|")[1] for parent in (joints_parents + geo_parents) } - self.log.info(parents_set) + self.log.debug(parents_set) if len(set(parents_set)) > 2: raise PublishXmlValidationError( diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index b2cb2ebda2..5ba256f9f5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -140,12 +140,12 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, return if not self.validate_mesh and not self.validate_collision: - self.log.info("Validation of both mesh and collision names" - "is disabled.") + self.log.debug("Validation of both mesh and collision names" + "is disabled.") return if not instance.data.get("collisionMembers", None): - self.log.info("There are no collision objects to validate") + self.log.debug("There are no collision objects to validate") return invalid = self.get_invalid(instance) diff --git a/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py b/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py index a71849da00..14571203ea 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py +++ b/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py @@ -52,6 +52,6 @@ class ValidateVRayDistributedRendering(pyblish.api.InstancePlugin): renderlayer = instance.data.get("renderlayer") with lib.renderlayer(renderlayer): - cls.log.info("Enabling Distributed Rendering " - "ignore in batch mode..") + cls.log.debug("Enabling Distributed Rendering " + "ignore in batch mode..") cmds.setAttr(cls.ignored_attr, True) diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py index 06250f5779..a8085418e7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py @@ -54,7 +54,7 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): # has any yeti callback set or not since if the callback # is there it wouldn't error and if it weren't then # nothing happens because there are no yeti nodes. - cls.log.info( + cls.log.debug( "Yeti is loaded but no yeti nodes were found. " "Callback validation skipped.." ) @@ -62,7 +62,7 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): renderer = instance.data["renderer"] if renderer == "redshift": - cls.log.info("Redshift ignores any pre and post render callbacks") + cls.log.debug("Redshift ignores any pre and post render callbacks") return False callback_lookup = cls.callbacks.get(renderer, {}) diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py index 96fb475a0a..50a27589ad 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py @@ -37,8 +37,8 @@ class ValidateYetiRigInputShapesInInstance(pyblish.api.Validator): # Allow publish without input meshes. if not shapes: - cls.log.info("Found no input meshes for %s, skipping ..." - % instance) + cls.log.debug("Found no input meshes for %s, skipping ..." + % instance) return [] # check if input node is part of groomRig instance From eec1d82db2b605186d58fb5f860fcf89cea142c9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 09:54:37 +0200 Subject: [PATCH 180/429] Deadline: Tweak logging for artist-facing reports in publisher --- .../publish/collect_deadline_server_from_instance.py | 2 +- .../deadline/plugins/publish/submit_celaction_deadline.py | 6 +++--- .../deadline/plugins/publish/submit_fusion_deadline.py | 4 ++-- .../deadline/plugins/publish/submit_harmony_deadline.py | 2 +- .../plugins/publish/submit_houdini_remote_publish.py | 2 +- .../deadline/plugins/publish/submit_maya_deadline.py | 6 +++--- .../deadline/plugins/publish/submit_nuke_deadline.py | 8 ++++---- .../deadline/plugins/publish/submit_publish_job.py | 4 ++-- .../plugins/publish/validate_deadline_connection.py | 2 +- .../deadline/plugins/publish/validate_deadline_pools.py | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) 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 eadfc3c83e..98ecb019c5 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 @@ -23,7 +23,7 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): instance.data["deadlineUrl"] = self._collect_deadline_url(instance) instance.data["deadlineUrl"] = \ instance.data["deadlineUrl"].strip().rstrip("/") - self.log.info( + self.log.debug( "Using {} for submission.".format(instance.data["deadlineUrl"])) def _collect_deadline_url(self, render_instance): diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index ee28612b44..5a6537a767 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -183,10 +183,10 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): } plugin = payload["JobInfo"]["Plugin"] - self.log.info("using render plugin : {}".format(plugin)) + self.log.debug("using render plugin : {}".format(plugin)) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files(instance, render_path) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index a48596c6bf..70aa12956d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -233,8 +233,8 @@ class FusionSubmitDeadline( ) for index, key in enumerate(environment) }) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs url = "{}/api/jobs".format(deadline_url) diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 2c37268f04..9f8579cd23 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -369,7 +369,7 @@ class HarmonySubmitDeadline( # rendering, we need to unzip it. published_scene = Path( self.from_published_scene(False)) - self.log.info(f"Processing {published_scene.as_posix()}") + self.log.debug(f"Processing {published_scene.as_posix()}") xstage_path = self._unzip_scene_file(published_scene) render_path = xstage_path.parent / "renders" diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 68aa653804..39c0c3afe4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -162,7 +162,7 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): ) # Submit - self.log.info("Submitting..") + self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 75d24b28f0..615d167a9f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -434,7 +434,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, new_job_info.update(tiles_data["JobInfo"]) new_plugin_info.update(tiles_data["PluginInfo"]) - self.log.info("hashing {} - {}".format(file_index, file)) + self.log.debug("hashing {} - {}".format(file_index, file)) job_hash = hashlib.sha256( ("{}_{}".format(file_index, file)).encode("utf-8")) @@ -450,7 +450,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, ) file_index += 1 - self.log.info( + self.log.debug( "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) # Submit frame tile jobs @@ -560,7 +560,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, assembly_job_ids = [] num_assemblies = len(assembly_payloads) for i, payload in enumerate(assembly_payloads): - self.log.info( + self.log.debug( "submitting assembly job {} of {}".format(i + 1, num_assemblies) ) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index cfdeb4968b..ffa203e922 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -244,7 +244,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, # resolve any limit groups limit_groups = self.get_limit_groups() - self.log.info("Limit groups: `{}`".format(limit_groups)) + self.log.debug("Limit groups: `{}`".format(limit_groups)) payload = { "JobInfo": { @@ -387,10 +387,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, }) plugin = payload["JobInfo"]["Plugin"] - self.log.info("using render plugin : {}".format(plugin)) + self.log.debug("using render plugin : {}".format(plugin)) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files( diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index bf4411ef43..20bebe583f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -317,7 +317,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, # remove secondary pool payload["JobInfo"].pop("SecondaryPool", None) - self.log.info("Submitting Deadline job ...") + self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) @@ -454,7 +454,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, import getpass render_job = {} - self.log.info("Faking job data ...") + self.log.debug("Faking job data ...") render_job["Props"] = {} # Render job doesn't exist because we do not have prior submission. # We still use data from it so lets fake it. diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index a30401e7dc..d8933a191f 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -18,7 +18,7 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") - self.log.info( + self.log.debug( "We have deadline URL on instance {}".format( deadline_url)) assert deadline_url, "Requires Deadline Webservice URL" diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 594f0ef866..1179b9cee9 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -32,9 +32,9 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] - self.log.info("deadline_url::{}".format(deadline_url)) + self.log.debug("deadline_url::{}".format(deadline_url)) pools = DeadlineModule.get_deadline_pools(deadline_url, log=self.log) - self.log.info("pools::{}".format(pools)) + self.log.debug("pools::{}".format(pools)) formatting_data = { "pools_str": ",".join(pools) From 63923ff4d8c65fde9b984ac8f0e1d8973d127ef6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:10:33 +0200 Subject: [PATCH 181/429] Global: Tweak logging for artist-facing reports in publisher --- openpype/plugins/publish/cleanup.py | 6 +++-- openpype/plugins/publish/cleanup_farm.py | 8 +++--- openpype/plugins/publish/collect_audio.py | 8 +++--- openpype/plugins/publish/collect_hierarchy.py | 2 +- .../plugins/publish/collect_rendered_files.py | 12 ++++----- openpype/plugins/publish/extract_burnin.py | 18 ++++++------- .../publish/extract_color_transcode.py | 2 +- .../publish/extract_colorspace_data.py | 2 +- .../publish/extract_hierarchy_avalon.py | 2 +- .../publish/extract_hierarchy_to_ayon.py | 4 +-- .../plugins/publish/extract_review_slate.py | 4 +-- .../plugins/publish/extract_scanline_exr.py | 12 ++++----- openpype/plugins/publish/extract_thumbnail.py | 26 +++++++++---------- .../publish/extract_thumbnail_from_source.py | 14 +++++----- .../publish/extract_trim_video_audio.py | 6 ++--- openpype/plugins/publish/integrate.py | 6 ++--- .../plugins/publish/integrate_hero_version.py | 6 ++--- .../plugins/publish/integrate_thumbnail.py | 8 +++--- .../publish/integrate_thumbnail_ayon.py | 6 ++--- .../plugins/publish/validate_asset_docs.py | 4 +-- .../publish/validate_editorial_asset_name.py | 2 +- 21 files changed, 81 insertions(+), 77 deletions(-) diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index 573cd829e4..f9f504dd4f 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -69,7 +69,7 @@ class CleanUp(pyblish.api.InstancePlugin): skip_cleanup_filepaths.add(os.path.normpath(path)) if self.remove_temp_renders: - self.log.info("Cleaning renders new...") + self.log.debug("Cleaning renders new...") self.clean_renders(instance, skip_cleanup_filepaths) if [ef for ef in self.exclude_families @@ -95,7 +95,9 @@ class CleanUp(pyblish.api.InstancePlugin): return if instance.data.get("stagingDir_persistent"): - self.log.info("Staging dir: %s should be persistent" % staging_dir) + self.log.debug( + "Staging dir {} should be persistent".format(staging_dir) + ) return self.log.info("Removing staging directory {}".format(staging_dir)) diff --git a/openpype/plugins/publish/cleanup_farm.py b/openpype/plugins/publish/cleanup_farm.py index 8052f13734..e655437ced 100644 --- a/openpype/plugins/publish/cleanup_farm.py +++ b/openpype/plugins/publish/cleanup_farm.py @@ -26,10 +26,10 @@ class CleanUpFarm(pyblish.api.ContextPlugin): # Skip process if is not in list of source hosts in which this # plugin should run if src_host_name not in self.allowed_hosts: - self.log.info(( + self.log.debug( "Source host \"{}\" is not in list of enabled hosts {}." - " Skipping" - ).format(str(src_host_name), str(self.allowed_hosts))) + " Skipping".format(src_host_name, self.allowed_hosts) + ) return self.log.debug("Preparing filepaths to remove") @@ -47,7 +47,7 @@ class CleanUpFarm(pyblish.api.ContextPlugin): dirpaths_to_remove.add(os.path.normpath(staging_dir)) if not dirpaths_to_remove: - self.log.info("Nothing to remove. Skipping") + self.log.debug("Nothing to remove. Skipping") return self.log.debug("Filepaths to remove are:\n{}".format( diff --git a/openpype/plugins/publish/collect_audio.py b/openpype/plugins/publish/collect_audio.py index 3a0ddb3281..6aaadfc568 100644 --- a/openpype/plugins/publish/collect_audio.py +++ b/openpype/plugins/publish/collect_audio.py @@ -53,8 +53,8 @@ class CollectAudio(pyblish.api.ContextPlugin): ): # Skip instances that already have audio filled if instance.data.get("audio"): - self.log.info( - "Skipping Audio collecion. It is already collected" + self.log.debug( + "Skipping Audio collection. It is already collected" ) continue filtered_instances.append(instance) @@ -70,7 +70,7 @@ class CollectAudio(pyblish.api.ContextPlugin): instances_by_asset_name[asset_name].append(instance) asset_names = set(instances_by_asset_name.keys()) - self.log.info(( + self.log.debug(( "Searching for audio subset '{subset}' in assets {assets}" ).format( subset=self.audio_subset_name, @@ -100,7 +100,7 @@ class CollectAudio(pyblish.api.ContextPlugin): "offset": 0, "filename": repre_path }] - self.log.info("Audio Data added to instance ...") + self.log.debug("Audio Data added to instance ...") def query_representations(self, project_name, asset_names): """Query representations related to audio subsets for passed assets. diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 687397be8a..b5fd1e4bb9 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -24,7 +24,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): final_context[project_name]['entity_type'] = 'Project' for instance in context: - self.log.info("Processing instance: `{}` ...".format(instance)) + self.log.debug("Processing instance: `{}` ...".format(instance)) # shot data dict shot_data = {} diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 6c8d1e9ca5..aaf290ace7 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -91,12 +91,12 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # now we can just add instances from json file and we are done for instance_data in data.get("instances"): - self.log.info(" - processing instance for {}".format( + self.log.debug(" - processing instance for {}".format( instance_data.get("subset"))) instance = self._context.create_instance( instance_data.get("subset") ) - self.log.info("Filling stagingDir...") + self.log.debug("Filling stagingDir...") self._fill_staging_dir(instance_data, anatomy) instance.data.update(instance_data) @@ -121,7 +121,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): "offset": 0 }] }) - self.log.info( + self.log.debug( f"Adding audio to instance: {instance.data['audio']}") def process(self, context): @@ -137,11 +137,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # Using already collected Anatomy anatomy = context.data["anatomy"] - self.log.info("Getting root setting for project \"{}\"".format( + self.log.debug("Getting root setting for project \"{}\"".format( anatomy.project_name )) - self.log.info("anatomy: {}".format(anatomy.roots)) + self.log.debug("anatomy: {}".format(anatomy.roots)) try: session_is_set = False for path in paths: @@ -156,7 +156,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): if remapped: session_data["AVALON_WORKDIR"] = remapped - self.log.info("Setting session using data from file") + self.log.debug("Setting session using data from file") legacy_io.Session.update(session_data) os.environ.update(session_data) session_is_set = True diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index e5b37ee3b4..dc8aab6ce4 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -83,7 +83,7 @@ class ExtractBurnin(publish.Extractor): return if not instance.data.get("representations"): - self.log.info( + self.log.debug( "Instance does not have filled representations. Skipping") return @@ -135,11 +135,11 @@ class ExtractBurnin(publish.Extractor): burnin_defs, repre["tags"] ) if not repre_burnin_defs: - self.log.info(( + self.log.debug( "Skipped representation. All burnin definitions from" - " selected profile does not match to representation's" - " tags. \"{}\"" - ).format(str(repre["tags"]))) + " selected profile do not match to representation's" + " tags. \"{}\"".format(repre["tags"]) + ) continue filtered_repres.append((repre, repre_burnin_defs)) @@ -164,7 +164,7 @@ class ExtractBurnin(publish.Extractor): logger=self.log) if not profile: - self.log.info(( + self.log.debug(( "Skipped instance. None of profiles in presets are for" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Task type \"{}\" | Subset \"{}\" " @@ -176,7 +176,7 @@ class ExtractBurnin(publish.Extractor): # Pre-filter burnin definitions by instance families burnin_defs = self.filter_burnins_defs(profile, instance) if not burnin_defs: - self.log.info(( + self.log.debug(( "Skipped instance. Burnin definitions are not set for profile" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Profile \"{}\"" @@ -223,10 +223,10 @@ class ExtractBurnin(publish.Extractor): # If result is None the requirement of conversion can't be # determined if do_convert is None: - self.log.info(( + self.log.debug( "Can't determine if representation requires conversion." " Skipped." - )) + ) continue # Do conversion if needed diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py index f7c8af9318..dbf1b6c8a6 100644 --- a/openpype/plugins/publish/extract_color_transcode.py +++ b/openpype/plugins/publish/extract_color_transcode.py @@ -320,7 +320,7 @@ class ExtractOIIOTranscode(publish.Extractor): logger=self.log) if not profile: - self.log.info(( + self.log.debug(( "Skipped instance. None of profiles in presets are for" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Task type \"{}\" | Subset \"{}\" " diff --git a/openpype/plugins/publish/extract_colorspace_data.py b/openpype/plugins/publish/extract_colorspace_data.py index 363df28fb5..8873dcd637 100644 --- a/openpype/plugins/publish/extract_colorspace_data.py +++ b/openpype/plugins/publish/extract_colorspace_data.py @@ -30,7 +30,7 @@ class ExtractColorspaceData(publish.Extractor, def process(self, instance): representations = instance.data.get("representations") if not representations: - self.log.info("No representations at instance : `{}`".format( + self.log.debug("No representations at instance : `{}`".format( instance)) return diff --git a/openpype/plugins/publish/extract_hierarchy_avalon.py b/openpype/plugins/publish/extract_hierarchy_avalon.py index 1d57545bc0..d70f0cbdd7 100644 --- a/openpype/plugins/publish/extract_hierarchy_avalon.py +++ b/openpype/plugins/publish/extract_hierarchy_avalon.py @@ -21,7 +21,7 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): return if "hierarchyContext" not in context.data: - self.log.info("skipping IntegrateHierarchyToAvalon") + self.log.debug("skipping ExtractHierarchyToAvalon") return if not legacy_io.Session: diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index 915650ae41..bb0081b7a5 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -27,13 +27,13 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): hierarchy_context = context.data.get("hierarchyContext") if not hierarchy_context: - self.log.info("Skipping") + self.log.debug("Skipping ExtractHierarchyToAYON") return project_name = context.data["projectName"] hierarchy_context = self._filter_hierarchy(context) if not hierarchy_context: - self.log.info("All folders were filtered out") + self.log.debug("All folders were filtered out") return self.log.debug("Hierarchy_context: {}".format( diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 886384fee6..8b1a06b6b8 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -46,7 +46,7 @@ class ExtractReviewSlate(publish.Extractor): "*": inst_data["slateFrame"] } - self.log.info("_ slates_data: {}".format(pformat(slates_data))) + self.log.debug("_ slates_data: {}".format(pformat(slates_data))) if "reviewToWidth" in inst_data: use_legacy_code = True @@ -76,7 +76,7 @@ class ExtractReviewSlate(publish.Extractor): ) # get slate data slate_path = self._get_slate_path(input_file, slates_data) - self.log.info("_ slate_path: {}".format(slate_path)) + self.log.debug("_ slate_path: {}".format(slate_path)) slate_width, slate_height = self._get_slates_resolution(slate_path) diff --git a/openpype/plugins/publish/extract_scanline_exr.py b/openpype/plugins/publish/extract_scanline_exr.py index 9f22794a79..747155689b 100644 --- a/openpype/plugins/publish/extract_scanline_exr.py +++ b/openpype/plugins/publish/extract_scanline_exr.py @@ -29,24 +29,24 @@ class ExtractScanlineExr(pyblish.api.InstancePlugin): representations_new = [] for repre in representations: - self.log.info( + self.log.debug( "Processing representation {}".format(repre.get("name"))) tags = repre.get("tags", []) if "toScanline" not in tags: - self.log.info(" - missing toScanline tag") + self.log.debug(" - missing toScanline tag") continue # run only on exrs if repre.get("ext") != "exr": - self.log.info("- not EXR files") + self.log.debug("- not EXR files") continue if not isinstance(repre['files'], (list, tuple)): input_files = [repre['files']] - self.log.info("We have a single frame") + self.log.debug("We have a single frame") else: input_files = repre['files'] - self.log.info("We have a sequence") + self.log.debug("We have a sequence") stagingdir = os.path.normpath(repre.get("stagingDir")) @@ -68,7 +68,7 @@ class ExtractScanlineExr(pyblish.api.InstancePlugin): ] subprocess_exr = " ".join(oiio_cmd) - self.log.info(f"running: {subprocess_exr}") + self.log.debug(f"running: {subprocess_exr}") run_subprocess(subprocess_exr, logger=self.log) # raise error if there is no ouptput diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index b72a6d02ad..cc2892213a 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -43,12 +43,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Skip if instance have 'review' key in data set to 'False' if not self._is_review_instance(instance): - self.log.info("Skipping - no review set on instance.") + self.log.debug("Skipping - no review set on instance.") return # Check if already has thumbnail created if self._already_has_thumbnail(instance_repres): - self.log.info("Thumbnail representation already present.") + self.log.debug("Thumbnail representation already present.") return # skip crypto passes. @@ -58,15 +58,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # representation that can be determined much earlier and # with better precision. if "crypto" in subset_name.lower(): - self.log.info("Skipping crypto passes.") + self.log.debug("Skipping crypto passes.") return filtered_repres = self._get_filtered_repres(instance) if not filtered_repres: - self.log.info(( - "Instance don't have representations" - " that can be used as source for thumbnail. Skipping" - )) + self.log.info( + "Instance doesn't have representations that can be used " + "as source for thumbnail. Skipping thumbnail extraction." + ) return # Create temp directory for thumbnail @@ -107,10 +107,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # oiiotool isn't available if not thumbnail_created: if oiio_supported: - self.log.info(( + self.log.debug( "Converting with FFMPEG because input" " can't be read by OIIO." - )) + ) thumbnail_created = self.create_thumbnail_ffmpeg( full_input_path, full_output_path @@ -165,8 +165,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): continue if not repre.get("files"): - self.log.info(( - "Representation \"{}\" don't have files. Skipping" + self.log.debug(( + "Representation \"{}\" doesn't have files. Skipping" ).format(repre["name"])) continue @@ -174,7 +174,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return filtered_repres def create_thumbnail_oiio(self, src_path, dst_path): - self.log.info("Extracting thumbnail {}".format(dst_path)) + self.log.debug("Extracting thumbnail with OIIO: {}".format(dst_path)) oiio_cmd = get_oiio_tool_args( "oiiotool", "-a", src_path, @@ -192,7 +192,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False def create_thumbnail_ffmpeg(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.debug("Extracting thumbnail with FFMPEG: {}".format(dst_path)) ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index 1b9f0a8bae..c1f271a8cc 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -49,7 +49,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # Check if already has thumbnail created if self._instance_has_thumbnail(instance): - self.log.info("Thumbnail representation already present.") + self.log.debug("Thumbnail representation already present.") return dst_filepath = self._create_thumbnail( @@ -98,7 +98,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): thumbnail_created = False oiio_supported = is_oiio_supported() - self.log.info("Thumbnail source: {}".format(thumbnail_source)) + self.log.debug("Thumbnail source: {}".format(thumbnail_source)) src_basename = os.path.basename(thumbnail_source) dst_filename = os.path.splitext(src_basename)[0] + "_thumb.jpg" full_output_path = os.path.join(dst_staging, dst_filename) @@ -115,10 +115,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # oiiotool isn't available if not thumbnail_created: if oiio_supported: - self.log.info(( + self.log.info( "Converting with FFMPEG because input" " can't be read by OIIO." - )) + ) thumbnail_created = self.create_thumbnail_ffmpeg( thumbnail_source, full_output_path @@ -143,14 +143,14 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return False def create_thumbnail_oiio(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) oiio_cmd = get_oiio_tool_args( "oiiotool", "-a", src_path, "--ch", "R,G,B", "-o", dst_path ) - self.log.info("Running: {}".format(" ".join(oiio_cmd))) + self.log.debug("Running: {}".format(" ".join(oiio_cmd))) try: run_subprocess(oiio_cmd, logger=self.log) return True @@ -173,7 +173,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): dst_path ) - self.log.info("Running: {}".format(" ".join(ffmpeg_cmd))) + self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd))) try: run_subprocess(ffmpeg_cmd, logger=self.log) return True diff --git a/openpype/plugins/publish/extract_trim_video_audio.py b/openpype/plugins/publish/extract_trim_video_audio.py index 2907ae1839..5e00cfc96f 100644 --- a/openpype/plugins/publish/extract_trim_video_audio.py +++ b/openpype/plugins/publish/extract_trim_video_audio.py @@ -36,7 +36,7 @@ class ExtractTrimVideoAudio(publish.Extractor): # get staging dir staging_dir = self.staging_dir(instance) - self.log.info("Staging dir set to: `{}`".format(staging_dir)) + self.log.debug("Staging dir set to: `{}`".format(staging_dir)) # Generate mov file. fps = instance.data["fps"] @@ -59,7 +59,7 @@ class ExtractTrimVideoAudio(publish.Extractor): extensions = [output_file_type] for ext in extensions: - self.log.info("Processing ext: `{}`".format(ext)) + self.log.debug("Processing ext: `{}`".format(ext)) if not ext.startswith("."): ext = "." + ext @@ -98,7 +98,7 @@ class ExtractTrimVideoAudio(publish.Extractor): ffmpeg_args.append(clip_trimed_path) joined_args = " ".join(ffmpeg_args) - self.log.info(f"Processing: {joined_args}") + self.log.debug(f"Processing: {joined_args}") run_subprocess( ffmpeg_args, logger=self.log ) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index be07cffe72..0c18ab4466 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -155,13 +155,13 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Instance should be integrated on a farm if instance.data.get("farm"): - self.log.info( + self.log.debug( "Instance is marked to be processed on farm. Skipping") return # Instance is marked to not get integrated if not instance.data.get("integrate", True): - self.log.info("Instance is marked to skip integrating. Skipping") + self.log.debug("Instance is marked to skip integrating. Skipping") return filtered_repres = self.filter_representations(instance) @@ -306,7 +306,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # increase if the file transaction takes a long time. op_session.commit() - self.log.info("Subset {subset[name]} and Version {version[name]} " + self.log.info("Subset '{subset[name]}' and Version {version[name]} " "written to database..".format(subset=subset, version=version)) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 6c21664b78..9f0f7fe7f3 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -275,10 +275,10 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): backup_hero_publish_dir = _backup_hero_publish_dir break except Exception: - self.log.info(( + self.log.info( "Could not remove previous backup folder." - " Trying to add index to folder name" - )) + " Trying to add index to folder name." + ) _backup_hero_publish_dir = ( backup_hero_publish_dir + str(idx) diff --git a/openpype/plugins/publish/integrate_thumbnail.py b/openpype/plugins/publish/integrate_thumbnail.py index 9929d8f754..0c12255d38 100644 --- a/openpype/plugins/publish/integrate_thumbnail.py +++ b/openpype/plugins/publish/integrate_thumbnail.py @@ -41,7 +41,9 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): def process(self, context): if AYON_SERVER_ENABLED: - self.log.info("AYON is enabled. Skipping v3 thumbnail integration") + self.log.debug( + "AYON is enabled. Skipping v3 thumbnail integration" + ) return # Filter instances which can be used for integration @@ -74,14 +76,14 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): thumbnail_template = anatomy.templates["publish"]["thumbnail"] if not thumbnail_template: - self.log.info("Thumbnail template is not filled. Skipping.") + self.log.debug("Thumbnail template is not filled. Skipping.") return if ( not thumbnail_root and thumbnail_root_format_key in thumbnail_template ): - self.log.warning(("{} is not set. Skipping.").format(env_key)) + self.log.warning("{} is not set. Skipping.".format(env_key)) return # Collect verion ids from all filtered instance diff --git a/openpype/plugins/publish/integrate_thumbnail_ayon.py b/openpype/plugins/publish/integrate_thumbnail_ayon.py index ba5664c69f..cf05327ce8 100644 --- a/openpype/plugins/publish/integrate_thumbnail_ayon.py +++ b/openpype/plugins/publish/integrate_thumbnail_ayon.py @@ -35,13 +35,13 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): def process(self, context): if not AYON_SERVER_ENABLED: - self.log.info("AYON is not enabled. Skipping") + self.log.debug("AYON is not enabled. Skipping") return # Filter instances which can be used for integration filtered_instance_items = self._prepare_instances(context) if not filtered_instance_items: - self.log.info( + self.log.debug( "All instances were filtered. Thumbnail integration skipped." ) return @@ -110,7 +110,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): # Skip instance if thumbnail path is not available for it if not thumbnail_path: - self.log.info(( + self.log.debug(( "Skipping thumbnail integration for instance \"{}\"." " Instance and context" " thumbnail paths are not available." diff --git a/openpype/plugins/publish/validate_asset_docs.py b/openpype/plugins/publish/validate_asset_docs.py index 9a1ca5b8de..8dfd783c39 100644 --- a/openpype/plugins/publish/validate_asset_docs.py +++ b/openpype/plugins/publish/validate_asset_docs.py @@ -22,11 +22,11 @@ class ValidateAssetDocs(pyblish.api.InstancePlugin): return if instance.data.get("assetEntity"): - self.log.info("Instance has set asset document in its data.") + self.log.debug("Instance has set asset document in its data.") elif instance.data.get("newAssetPublishing"): # skip if it is editorial - self.log.info("Editorial instance is no need to check...") + self.log.debug("Editorial instance has no need to check...") else: raise PublishValidationError(( diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index 4f8a1abf2e..fca0d8e7f5 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -56,7 +56,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): } continue - self.log.info("correct asset: {}".format(asset)) + self.log.debug("correct asset: {}".format(asset)) if assets_missing_name: wrong_names = {} From 7243f7e7219cd59aebb5c970666bb5d8552d378b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:11:30 +0200 Subject: [PATCH 182/429] Fix context not being reported correctly if it was already set on `context.data` prior to collector --- openpype/plugins/publish/collect_current_context.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/collect_current_context.py b/openpype/plugins/publish/collect_current_context.py index 166d75e5de..8b12a3f77f 100644 --- a/openpype/plugins/publish/collect_current_context.py +++ b/openpype/plugins/publish/collect_current_context.py @@ -39,5 +39,12 @@ class CollectCurrentContext(pyblish.api.ContextPlugin): # - 'task' -> 'taskName' self.log.info(( - "Collected project context\nProject: {}\nAsset: {}\nTask: {}" - ).format(project_name, asset_name, task_name)) + "Collected project context\n" + "Project: {project_name}\n" + "Asset: {asset_name}\n" + "Task: {task_name}" + ).format( + project_name=context.data["projectName"], + asset_name=context.data["asset"], + task_name=context.data["task"] + )) From d03d0564cdd471ce8ac3c0fdcf85db4841c69508 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:13:07 +0200 Subject: [PATCH 183/429] Fix typo `thubmnail` -> `thumbnail` --- openpype/plugins/publish/extract_thumbnail.py | 2 +- openpype/plugins/publish/extract_thumbnail_from_source.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index cc2892213a..de101ac7ac 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -225,7 +225,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using ffmpeg", + "Failed to create thumbnail using ffmpeg", exc_info=True ) return False diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index c1f271a8cc..401a5d615d 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -156,7 +156,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using oiiotool", + "Failed to create thumbnail using oiiotool", exc_info=True ) return False @@ -179,7 +179,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using ffmpeg", + "Failed to create thumbnail using ffmpeg", exc_info=True ) return False From 2ba42fe376bb6b0fec30cc1a37fb634b7ba69318 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:14:53 +0200 Subject: [PATCH 184/429] Tweak logging level --- openpype/hosts/fusion/plugins/publish/collect_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index 6016baa2a9..4d6da79b77 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -85,5 +85,5 @@ class CollectInstanceData(pyblish.api.InstancePlugin): # Add review family if the instance is marked as 'review' # This could be done through a 'review' Creator attribute. if instance.data.get("review", False): - self.log.info("Adding review family..") + self.log.debug("Adding review family..") instance.data["families"].append("review") From ca8305a0cf2a48bb5c9965c6cab9b4bc14aaf549 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:16:37 +0200 Subject: [PATCH 185/429] Tweak logging level of removal of directory in tempdir to debug since artist doesn't care about seeing that in artist facing report --- openpype/plugins/publish/cleanup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index f9f504dd4f..6c122ddf09 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -100,7 +100,7 @@ class CleanUp(pyblish.api.InstancePlugin): ) return - self.log.info("Removing staging directory {}".format(staging_dir)) + self.log.debug("Removing staging directory {}".format(staging_dir)) shutil.rmtree(staging_dir) def clean_renders(self, instance, skip_cleanup_filepaths): From e9cb011c2a38084bc2318e4b15d011e2278f0c38 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:31:06 +0200 Subject: [PATCH 186/429] Remove unused attribute --- openpype/hosts/maya/plugins/publish/collect_history.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_history.py b/openpype/hosts/maya/plugins/publish/collect_history.py index 71f0169971..d4e8c6298b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_history.py +++ b/openpype/hosts/maya/plugins/publish/collect_history.py @@ -18,7 +18,6 @@ class CollectMayaHistory(pyblish.api.InstancePlugin): hosts = ["maya"] label = "Maya History" families = ["rig"] - verbose = False def process(self, instance): From 7be680190f0cf97da06e02747b4a4c8ea9ff46fc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 30 Aug 2023 17:29:53 +0800 Subject: [PATCH 187/429] bug fix on the ornatrix loader --- .../plugins/load/load_pointcache_ornatrix.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index a3c9d94f83..56cac00aeb 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -22,18 +22,20 @@ class OxAbcLoader(load.LoaderPlugin): "found/installed in Max yet..") file_path = os.path.normpath(self.filepath_from_context(context)) - scene_object_before = [obj for obj in rt.rootNode.Children] rt.AlembicImport.ImportToRoot = True rt.AlembicImport.CustomAttributes = True rt.importFile( file_path, rt.name("noPrompt"), using=rt.Ornatrix_Alembic_Importer) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) + + scene_object = [] + for obj in rt.rootNode.Children: + obj_type = rt.ClassOf(obj) + if str(obj_type).startswith("Ox_"): + scene_object.append(obj) abc_container = rt.Container(name=name) - for abc in scene_object_after: + for abc in scene_object: abc.Parent = abc_container return containerise( @@ -48,18 +50,20 @@ class OxAbcLoader(load.LoaderPlugin): for children in container.Children: rt.Delete(children) - scene_object_before = [obj for obj in rt.rootNode.Children] rt.AlembicImport.ImportToRoot = False rt.AlembicImport.CustomAttributes = True rt.importFile( path, rt.name("noPrompt"), using=rt.Ornatrix_Alembic_Importer) - scene_object_after = [obj for obj in rt.rootNode.Children] - for scene_object in scene_object_before: - scene_object_after = scene_object_after.remove(scene_object) - for scene_object in scene_object_after: - scene_object.Parent = container + scene_object = [] + for obj in rt.rootNode.Children: + obj_type = rt.ClassOf(obj) + if str(obj_type).startswith("Ox_"): + scene_object.append(obj) + + for abc in scene_object: + abc.Parent = container lib.imprint( container["instance_node"], From 97f9cd4377e83cfec48ae8d18bcc90d783185e60 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:14:33 +0200 Subject: [PATCH 188/429] Do not warn about renderlayer instance being empty --- openpype/hosts/maya/plugins/publish/collect_instances.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_instances.py b/openpype/hosts/maya/plugins/publish/collect_instances.py index 5f914b40d7..5058da3d01 100644 --- a/openpype/hosts/maya/plugins/publish/collect_instances.py +++ b/openpype/hosts/maya/plugins/publish/collect_instances.py @@ -28,6 +28,8 @@ class CollectNewInstances(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder hosts = ["maya"] + valid_empty_families = {"workfile", "renderlayer"} + def process(self, instance): objset = instance.data.get("instance_node") @@ -58,7 +60,7 @@ class CollectNewInstances(pyblish.api.InstancePlugin): instance[:] = members_hierarchy - elif instance.data["family"] != "workfile": + elif instance.data["family"] not in self.valid_empty_families: self.log.warning("Empty instance: \"%s\" " % objset) # Store the exact members of the object set instance.data["setMembers"] = members From 0fa081dddb6e6295748f338daa822e1e2cd61d40 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:16:27 +0200 Subject: [PATCH 189/429] Ignore arnold attribute check if arnold is not loaded --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 55624726ea..59ba326d45 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -109,6 +109,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return + if not cmds.pluginInfo("mtoa", query=True, loaded=True): + # Arnold attributes only exist if plug-in is loaded + return + invalid = self.get_invalid_attributes(instance, compute=True) if invalid: raise PublishValidationError( From 17f83a0eccb99a35ba44faaf876ad3604b342932 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:17:16 +0200 Subject: [PATCH 190/429] Avoid iterating over `None` if `ai` attributes for some reason do not exist --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 59ba326d45..ddb8d2bf9d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -51,7 +51,7 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, tmp.append(transform) mesh = cmds.createNode("mesh", parent=transform) - for attr in cmds.listAttr(mesh, string="ai*"): + for attr in cmds.listAttr(mesh, string="ai*") or []: plug = "{}.{}".format(mesh, attr) try: defaults[attr] = get_attribute(plug) From 861b9849cb11b82ad70ef99806c1c0498ec30b1b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:20:56 +0200 Subject: [PATCH 191/429] Ensure we only iterate `ai*` attributes that come from a plugin --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index ddb8d2bf9d..849d60653a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -51,7 +51,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, tmp.append(transform) mesh = cmds.createNode("mesh", parent=transform) - for attr in cmds.listAttr(mesh, string="ai*") or []: + arnold_attributes = cmds.listAttr(mesh, + string="ai*", + fromPlugin=True) or [] + for attr in arnold_attributes: plug = "{}.{}".format(mesh, attr) try: defaults[attr] = get_attribute(plug) From 3ee28bd1aa092f90e5b807dc63eea135eb913a8b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:22:11 +0200 Subject: [PATCH 192/429] Avoid selection changes --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 849d60653a..d50e1a56c7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -47,10 +47,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, # Get default arnold attribute values for mesh type. defaults = {} with delete_after() as tmp: - transform = cmds.createNode("transform") + transform = cmds.createNode("transform", skipSelect=True) tmp.append(transform) - mesh = cmds.createNode("mesh", parent=transform) + mesh = cmds.createNode("mesh", parent=transform, skipSelect=True) arnold_attributes = cmds.listAttr(mesh, string="ai*", fromPlugin=True) or [] From 3b8df1ac2bad6df683ae4417e2b2e0b59944f729 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:29:40 +0200 Subject: [PATCH 193/429] Cache arnold mesh attribute defaults so multiple "model" instance don't try and retrieve the default values every time. --- .../plugins/publish/validate_mesh_arnold_attributes.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index d50e1a56c7..526ebbac99 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -36,6 +36,9 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, optional = True + # cache (will be `dict` when cached) + arnold_mesh_defaults = None + @classmethod def apply_settings(cls, project_settings, system_settings): # todo: this should not be done this way @@ -44,6 +47,11 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, @classmethod def get_default_attributes(cls): + + if cls.arnold_mesh_defaults is not None: + # Use from cache + return cls.arnold_mesh_defaults + # Get default arnold attribute values for mesh type. defaults = {} with delete_after() as tmp: @@ -61,6 +69,7 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, except PublishValidationError: cls.log.debug("Ignoring arnold attribute: {}".format(attr)) + cls.arnold_mesh_defaults = defaults # assign cache return defaults @classmethod From 689da974c4d22161797862f2a9e77d8aeee65656 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:42:46 +0200 Subject: [PATCH 194/429] Removed `apply_settings` override to fix plugin settings actually being applied This makes it so again that it's up to the studio admin to enable or disable the plug-in per project instead of whether the modeler's workfile just happened to have the current renderer set to arnold or not. Note that the plug-in is currently disabled by default in project settings. --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 526ebbac99..bde78a98b8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -39,12 +39,6 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, # cache (will be `dict` when cached) arnold_mesh_defaults = None - @classmethod - def apply_settings(cls, project_settings, system_settings): - # todo: this should not be done this way - attr = "defaultRenderGlobals.currentRenderer" - cls.active = cmds.getAttr(attr).lower() == "arnold" - @classmethod def get_default_attributes(cls): From b83a40931385b0688cad416a7a998fb9b3f6f7c1 Mon Sep 17 00:00:00 2001 From: sjt-rvx <72554834+sjt-rvx@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:12:03 +0000 Subject: [PATCH 195/429] have the addons loading respect a custom AYON_ADDONS_DIR (#5539) * have the addons loading respect a custom AYON_ADDONS_DIR * Update openpype/modules/base.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/modules/base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 9b3637c48a..84e213288c 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -373,10 +373,12 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addons_info = _get_ayon_addons_information() if not addons_info: return v3_addons_to_skip - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) + addons_dir = os.environ.get("AYON_ADDONS_DIR") + if not addons_dir: + addons_dir = os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + "addons" + ) if not os.path.exists(addons_dir): log.warning("Addons directory does not exists. Path \"{}\"".format( addons_dir From 3c3438532018c6360b77a2b5c86959638e73d86f Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Wed, 30 Aug 2023 16:35:09 +0300 Subject: [PATCH 196/429] Houdini: Improve VDB Selection (#5523) * improve sop selection * resolve hound conversations * resolve BigRoy's comments --- .../plugins/create/create_vbd_cache.py | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py index c015cebd49..9c96e48e3a 100644 --- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py +++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py @@ -33,7 +33,7 @@ class CreateVDBCache(plugin.HoudiniCreator): } if self.selected_nodes: - parms["soppath"] = self.selected_nodes[0].path() + parms["soppath"] = self.get_sop_node_path(self.selected_nodes[0]) instance_node.setParms(parms) @@ -42,3 +42,63 @@ class CreateVDBCache(plugin.HoudiniCreator): hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] + + def get_sop_node_path(self, selected_node): + """Get Sop Path of the selected node. + + Although Houdini allows ObjNode path on `sop_path` for the + the ROP node, we prefer it set to the SopNode path explicitly. + """ + + # Allow sop level paths (e.g. /obj/geo1/box1) + if isinstance(selected_node, hou.SopNode): + self.log.debug( + "Valid SopNode selection, 'SOP Path' in ROP will" + " be set to '%s'.", selected_node.path() + ) + return selected_node.path() + + # Allow object level paths to Geometry nodes (e.g. /obj/geo1) + # but do not allow other object level nodes types like cameras, etc. + elif isinstance(selected_node, hou.ObjNode) and \ + selected_node.type().name() == "geo": + + # Try to find output node. + sop_node = self.get_obj_output(selected_node) + if sop_node: + self.log.debug( + "Valid ObjNode selection, 'SOP Path' in ROP will " + "be set to the child path '%s'.", sop_node.path() + ) + return sop_node.path() + + self.log.debug( + "Selection isn't valid. 'SOP Path' in ROP will be empty." + ) + return "" + + def get_obj_output(self, obj_node): + """Try to find output node. + + If any output nodes are present, return the output node with + the minimum 'outputidx' + If no output nodes are present, return the node with display flag + If no nodes are present at all, return None + """ + + outputs = obj_node.subnetOutputs() + + # if obj_node is empty + if not outputs: + return + + # if obj_node has one output child whether its + # sop output node or a node with the render flag + elif len(outputs) == 1: + return outputs[0] + + # if there are more than one, then it has multiple output nodes + # return the one with the minimum 'outputidx' + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) From a60c3d7ce304f648e13c6ab712a9a558b2186a65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:36:42 +0200 Subject: [PATCH 197/429] use correct git url in README (#5542) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92f1cb62dc..ce98f845e6 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ development tools like [CMake](https://cmake.org/) and [Visual Studio](https://v #### Clone repository: ```sh -git clone --recurse-submodules git@github.com:Pypeclub/OpenPype.git +git clone --recurse-submodules git@github.com:ynput/OpenPype.git ``` #### To build OpenPype: From 74d612208ec4dc0e3a31891ce93dfca5a02d4d48 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:47:03 +0200 Subject: [PATCH 198/429] AYON: Deadline expand userpaths in executables list (#5540) * expand userpaths set in executables list * Update logic for searching executable with expanduser --------- Co-authored-by: Petr Kalis --- .../deadline/repository/custom/plugins/Ayon/Ayon.py | 8 +++++++- .../repository/custom/plugins/GlobalJobPreLoad.py | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index 1544acc2a4..a29acf9823 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -91,7 +91,13 @@ class AyonDeadlinePlugin(DeadlinePlugin): # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") - exe = FileUtils.SearchFileList(exe_list) + + expanded_paths = [] + for path in exe_list.split(";"): + if path.startswith("~"): + path = os.path.expanduser(path) + expanded_paths.append(path) + exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": self.FailRender( diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 5f7e1f1032..97875215ae 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -547,7 +547,14 @@ def get_ayon_executable(): # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") - return exe_list + + # Expand user paths + expanded_paths = [] + for path in exe_list.split(";"): + if path.startswith("~"): + path = os.path.expanduser(path) + expanded_paths.append(path) + return ";".join(expanded_paths) def inject_render_job_id(deadlinePlugin): From 668bcb2d40d5708fc40c1e1a8465e36c94ffdef8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 30 Aug 2023 22:28:43 +0300 Subject: [PATCH 199/429] BigRoy's Comments --- openpype/hosts/houdini/api/colorspace.py | 19 +++++++------------ openpype/pipeline/colorspace.py | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 2662a968e2..fb0a724eb9 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -57,16 +57,11 @@ class ARenderProduct(object): def get_default_display_view_colorspace(): - """Get default display view colorspace. + """Get default display view colorspace name. """ - It's used for 'ociocolorspace' parm in OpneGL Node.""" - - data = get_color_management_preferences() - config_path = data.get("config") - display = data.get("display") - view = data.get("view") - - default_view_space = get_display_view_colorspace_name(config_path, - display, - view) - return default_view_space + prefs = get_color_management_preferences() + return get_display_view_colorspace_name( + config_path=prefs["config"], + display=prefs["display"], + view=prefs["view"] + ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 37974f4a0b..3bb258e8f2 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -650,7 +650,7 @@ def get_display_view_colorspace_subprocess(config_path, display, view): "--view", view ] - log.info("Executing: {}".format(" ".join(args))) + log.debug("Executing: {}".format(" ".join(args))) process_kwargs = { "logger": log From 41babcaa85880b957a6409bf29d02da2291f91d2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 30 Aug 2023 22:43:52 +0300 Subject: [PATCH 200/429] update doc strings --- openpype/hosts/houdini/api/colorspace.py | 4 +++- openpype/pipeline/colorspace.py | 4 ++-- openpype/scripts/ocio_wrapper.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index fb0a724eb9..b1a4d5dcd5 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -57,7 +57,9 @@ class ARenderProduct(object): def get_default_display_view_colorspace(): - """Get default display view colorspace name. """ + """Returns the colorspace attribute of the default (display, view) pair. + + """ prefs = get_color_management_preferences() return get_display_view_colorspace_name( diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3bb258e8f2..3dd33d0425 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -604,7 +604,7 @@ def _get_imageio_settings(project_settings, host_name): def get_display_view_colorspace_name(config_path, display, view): - """Return colorspace name for the given display and view. + """Returns the colorspace attribute of the (display, view) pair. Args: config_path (str): path string leading to config.ocio @@ -627,7 +627,7 @@ def get_display_view_colorspace_name(config_path, display, view): def get_display_view_colorspace_subprocess(config_path, display, view): - """get view colorspace name for the given display and view + """Returns the colorspace attribute of the (display, view) pair via subprocess. Args: diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index e491206ebb..cae6e6975b 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -175,7 +175,7 @@ def _get_views_data(config_path): def _get_display_view_colorspace_name(config_path, display, view): - """get view colorspace name for the given display and view. + """Returns the colorspace attribute of the (display, view) pair. Args: config_path (str): path string leading to config.ocio From 6c5039f7075ce59b2911a680d41ffae5a59dca09 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 30 Aug 2023 22:46:07 +0300 Subject: [PATCH 201/429] BigRoy's Comment --- openpype/hosts/houdini/api/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index b1a4d5dcd5..cc40b9df1c 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -59,7 +59,7 @@ class ARenderProduct(object): def get_default_display_view_colorspace(): """Returns the colorspace attribute of the default (display, view) pair. - """ + It's used for 'ociocolorspace' parm in OpenGL Node.""" prefs = get_color_management_preferences() return get_display_view_colorspace_name( From 9e19f2f8ca1e7e848a7cebde4432df1fde3d3897 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:09:33 +0200 Subject: [PATCH 202/429] implemented multiselection EnumDef --- openpype/lib/attribute_definitions.py | 30 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 6054d2a92a..0af701a93a 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -434,7 +434,9 @@ class EnumDef(AbstractAttrDef): type = "enum" - def __init__(self, key, items, default=None, **kwargs): + def __init__( + self, key, items, default=None, multiselection=False, **kwargs + ): if not items: raise ValueError(( "Empty 'items' value. {} must have" @@ -443,7 +445,10 @@ class EnumDef(AbstractAttrDef): items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] - if default not in item_values: + if multiselection and default is None: + default = [] + + if not multiselection and default not in item_values: for value in item_values: default = value break @@ -452,21 +457,34 @@ class EnumDef(AbstractAttrDef): self.items = items self._item_values = set(item_values) + self.multiselection = multiselection def __eq__(self, other): if not super(EnumDef, self).__eq__(other): return False - return self.items == other.items + return ( + self.items == other.items + and self.multiselection == other.multiselection + ) def convert_value(self, value): - if value in self._item_values: - return value - return self.default + if not self.multiselection: + if value in self._item_values: + return value + return self.default + + if value is None: + return copy.deepcopy(self.default) + new_value = set(value) + rem = new_value - self._item_values + return list(new_value - rem) + def serialize(self): data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) + data["multiselection"] = self.multiselection return data @staticmethod From ebec7236aae738a2ffd9fd2738b9f402ead42c2e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:09:51 +0200 Subject: [PATCH 203/429] implemented multiselection combobox --- openpype/tools/utils/__init__.py | 3 + .../tools/utils/multiselection_combobox.py | 383 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 openpype/tools/utils/multiselection_combobox.py diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index f35bfaee70..d343353112 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -38,6 +38,7 @@ from .models import ( from .overlay_messages import ( MessageOverlayObject, ) +from .multiselection_combobox import MultiSelectionComboBox __all__ = ( @@ -78,4 +79,6 @@ __all__ = ( "RecursiveSortFilterProxyModel", "MessageOverlayObject", + + "MultiSelectionComboBox", ) diff --git a/openpype/tools/utils/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py new file mode 100644 index 0000000000..27576a449a --- /dev/null +++ b/openpype/tools/utils/multiselection_combobox.py @@ -0,0 +1,383 @@ +from qtpy import QtCore, QtGui, QtWidgets + +from .lib import ( + checkstate_int_to_enum, + checkstate_enum_to_int, +) +from .constants import ( + CHECKED_INT, + UNCHECKED_INT, + ITEM_IS_USER_TRISTATE, +) + + +class ComboItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Helper styled delegate (mostly based on existing private Qt's + delegate used by the QtWidgets.QComboBox). Used to style the popup like a + list view (e.g windows style). + """ + + def paint(self, painter, option, index): + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + # option.state &= ( + # ~QtWidgets.QStyle.State_HasFocus + # & ~QtWidgets.QStyle.State_MouseOver + # ) + super(ComboItemDelegate, self).paint(painter, option, index) + + +class MultiSelectionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + focused_in = QtCore.Signal() + + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End, + } + + top_bottom_padding = 2 + left_right_padding = 3 + left_offset = 4 + top_bottom_margins = 2 + item_spacing = 5 + + item_bg_color = QtGui.QColor("#31424e") + + def __init__( + self, parent=None, placeholder="", separator=", ", **kwargs + ): + super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + self.setObjectName("MultiSelectionComboBox") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._separator = separator + self._placeholder_text = placeholder + delegate = ComboItemDelegate(self) + self.setItemDelegate(delegate) + + self._lines = {} + self._item_height = None + self._custom_text = None + self._delegate = delegate + + def get_placeholder_text(self): + return self._placeholder_text + + def set_placeholder_text(self, text): + self._placeholder_text = text + + def set_custom_text(self, text): + self._custom_text = text + self.update() + self.updateGeometry() + + def focusInEvent(self, event): + self.focused_in.emit() + return super(MultiSelectionComboBox, self).focusInEvent(event) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super(MultiSelectionComboBox, self).mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super(MultiSelectionComboBox, self).showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super(MultiSelectionComboBox, self).hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = checkstate_int_to_enum( + current_index.data(QtCore.Qt.CheckStateRole) + ) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Unchecked: + new_state = CHECKED_INT + else: + new_state = UNCHECKED_INT + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & ITEM_IS_USER_TRISTATE + ): + new_state = (checkstate_enum_to_int(state) + 1) % 3 + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + # toggle the current items check state + if state != QtCore.Qt.Checked: + new_state = CHECKED_INT + else: + new_state = UNCHECKED_INT + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self._update_size_hint() + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super(MultiSelectionComboBox, self).eventFilter(obj, event) + + def addItem(self, *args, **kwargs): + idx = self.count() + super(MultiSelectionComboBox, self).addItem(*args, **kwargs) + self.model().item(idx).setCheckable(True) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + items = self.checked_items_text() + # draw the icon and text + draw_text = True + combotext = None + if self._custom_text is not None: + combotext = self._custom_text + elif not items: + combotext = self._placeholder_text + else: + draw_text = False + if draw_text: + option.currentText = combotext + option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) + painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) + return + + font_metricts = self.fontMetrics() + + if self._item_height is None: + self.updateGeometry() + self.update() + return + + for line, items in self._lines.items(): + top_y = ( + option.rect.top() + + (line * self._item_height) + + self.top_bottom_margins + ) + left_x = option.rect.left() + self.left_offset + for item in items: + label_rect = font_metricts.boundingRect(item) + label_height = label_rect.height() + + label_rect.moveTop(top_y) + label_rect.moveLeft(left_x) + label_rect.setHeight(self._item_height) + label_rect.setWidth( + label_rect.width() + self.left_right_padding + ) + + bg_rect = QtCore.QRectF(label_rect) + bg_rect.setWidth( + label_rect.width() + self.left_right_padding + ) + left_x = bg_rect.right() + self.item_spacing + + label_rect.moveLeft(label_rect.x() + self.left_right_padding) + + bg_rect.setHeight(label_height + (2 * self.top_bottom_padding)) + bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) + + path = QtGui.QPainterPath() + path.addRoundedRect(bg_rect, 5, 5) + + painter.fillPath(path, self.item_bg_color) + + painter.drawText( + label_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + item + ) + + def resizeEvent(self, *args, **kwargs): + super(MultiSelectionComboBox, self).resizeEvent(*args, **kwargs) + self._update_size_hint() + + def _update_size_hint(self): + if self._custom_text is not None: + self.update() + return + self._lines = {} + + items = self.checked_items_text() + if not items: + self.update() + return + + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + btn_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxArrow + ) + total_width = option.rect.width() - btn_rect.width() + + line = 0 + self._lines = {line: []} + + font_metricts = self.fontMetrics() + default_left_x = 0 + self.left_offset + left_x = int(default_left_x) + for item in items: + rect = font_metricts.boundingRect(item) + width = rect.width() + (2 * self.left_right_padding) + right_x = left_x + width + if right_x > total_width: + left_x = int(default_left_x) + if self._lines.get(line): + line += 1 + self._lines[line] = [item] + left_x += width + else: + self._lines[line] = [item] + line += 1 + else: + if line in self._lines: + self._lines[line].append(item) + else: + self._lines[line] = [item] + left_x = left_x + width + self.item_spacing + + self.update() + self.updateGeometry() + + def sizeHint(self): + value = super(MultiSelectionComboBox, self).sizeHint() + lines = 1 + if self._custom_text is None: + lines = len(self._lines) + if lines == 0: + lines = 1 + + if self._item_height is None: + self._item_height = ( + self.fontMetrics().height() + + (2 * self.top_bottom_padding) + + (2 * self.top_bottom_margins) + ) + value.setHeight( + (lines * self._item_height) + + (2 * self.top_bottom_margins) + ) + return value + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values): + for idx in range(self.count()): + value = self.itemData(idx, role=QtCore.Qt.UserRole) + if value in values: + check_state = CHECKED_INT + else: + check_state = UNCHECKED_INT + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + self._update_size_hint() + + def value(self): + items = list() + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + items.append( + self.itemData(idx, role=QtCore.Qt.UserRole) + ) + return items + + def checked_items_text(self): + items = list() + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + items.append(self.itemText(idx)) + return items + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super(MultiSelectionComboBox, self).keyPressEvent(event) From 2d04efba93c50e6832059ae92d49bee9aab641fb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:10:14 +0200 Subject: [PATCH 204/429] implemented multuselection EnumAttrDef widget --- openpype/tools/attribute_defs/widgets.py | 55 +++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index 7967416e9f..e08ca17225 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -19,6 +19,7 @@ from openpype.tools.utils import ( CustomTextComboBox, FocusSpinBox, FocusDoubleSpinBox, + MultiSelectionComboBox, ) from openpype.widgets.nice_checkbox import NiceCheckbox @@ -412,10 +413,19 @@ class EnumAttrWidget(_BaseAttrDefWidget): self._multivalue = False super(EnumAttrWidget, self).__init__(*args, **kwargs) + @property + def multiselection(self): + return self.attr_def.multiselection + def _ui_init(self): - input_widget = CustomTextComboBox(self) - combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) - input_widget.setItemDelegate(combo_delegate) + if self.multiselection: + input_widget = MultiSelectionComboBox(self) + + else: + input_widget = CustomTextComboBox(self) + combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) + input_widget.setItemDelegate(combo_delegate) + self._combo_delegate = combo_delegate if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) @@ -427,9 +437,11 @@ class EnumAttrWidget(_BaseAttrDefWidget): if idx >= 0: input_widget.setCurrentIndex(idx) - input_widget.currentIndexChanged.connect(self._on_value_change) + if self.multiselection: + input_widget.value_changed.connect(self._on_value_change) + else: + input_widget.currentIndexChanged.connect(self._on_value_change) - self._combo_delegate = combo_delegate self._input_widget = input_widget self.main_layout.addWidget(input_widget, 0) @@ -442,17 +454,40 @@ class EnumAttrWidget(_BaseAttrDefWidget): self.value_changed.emit(new_value, self.attr_def.id) def current_value(self): + if self.multiselection: + return self._input_widget.value() idx = self._input_widget.currentIndex() return self._input_widget.itemData(idx) def set_value(self, value, multivalue=False): if multivalue: - set_value = set(value) - if len(set_value) == 1: - multivalue = False - value = tuple(set_value)[0] + if self.multiselection: + _value = None + are_same = True + for v in value: + _v = set(v) + if _value is None: + _value = _v + elif not are_same or _value != _v: + _value |= _v + are_same = False + if _value is None: + _value = [] + else: + value = list(_value) + multivalue = not are_same + else: + set_value = set(value) + if len(set_value) == 1: + multivalue = False + value = tuple(set_value)[0] - if not multivalue: + if self.multiselection: + self._input_widget.blockSignals(True) + self._input_widget.set_value(value) + self._input_widget.blockSignals(False) + + elif not multivalue: idx = self._input_widget.findData(value) cur_idx = self._input_widget.currentIndex() if idx != cur_idx and idx >= 0: From 7ebec6b8bcd737b6d393d9a50b9f4744a2e841b0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:55:44 +0200 Subject: [PATCH 205/429] simplified few lines --- openpype/lib/attribute_definitions.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 0af701a93a..44aa8dee48 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -445,18 +445,19 @@ class EnumDef(AbstractAttrDef): items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] - if multiselection and default is None: - default = [] + item_values_set = set(item_values) + if multiselection: + if default is None: + default = [] + default = list(item_values_set.intersection(default)) - if not multiselection and default not in item_values: - for value in item_values: - default = value - break + elif default not in item_values: + default = next(iter(item_values), None) super(EnumDef, self).__init__(key, default=default, **kwargs) self.items = items - self._item_values = set(item_values) + self._item_values = item_values_set self.multiselection = multiselection def __eq__(self, other): @@ -476,9 +477,7 @@ class EnumDef(AbstractAttrDef): if value is None: return copy.deepcopy(self.default) - new_value = set(value) - rem = new_value - self._item_values - return list(new_value - rem) + return list(self._item_values.intersection(value)) def serialize(self): From 8cf4c9c6515c7eb6d9db92c57d48a21acf3821f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 23:03:45 +0200 Subject: [PATCH 206/429] move multiselection multivalue handling to separated method --- openpype/tools/attribute_defs/widgets.py | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index e08ca17225..adb29dcbd3 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -459,23 +459,23 @@ class EnumAttrWidget(_BaseAttrDefWidget): idx = self._input_widget.currentIndex() return self._input_widget.itemData(idx) + def _multiselection_multivalue_prep(self, values): + final = None + are_same = True + for value in values: + value = set(value) + if final is None: + final = value + elif not are_same or final != value: + final |= value + are_same = False + return list(final), not are_same + def set_value(self, value, multivalue=False): if multivalue: if self.multiselection: - _value = None - are_same = True - for v in value: - _v = set(v) - if _value is None: - _value = _v - elif not are_same or _value != _v: - _value |= _v - are_same = False - if _value is None: - _value = [] - else: - value = list(_value) - multivalue = not are_same + value, multivalue = self._multiselection_multivalue_prep( + value) else: set_value = set(value) if len(set_value) == 1: From 5b4bdeb6259dc5bd69ec773ba717a1efad17a695 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 23:14:54 +0200 Subject: [PATCH 207/429] avoid unnecessary negative checks --- openpype/tools/attribute_defs/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index adb29dcbd3..d9c55f4a64 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -461,15 +461,15 @@ class EnumAttrWidget(_BaseAttrDefWidget): def _multiselection_multivalue_prep(self, values): final = None - are_same = True + multivalue = False for value in values: value = set(value) if final is None: final = value - elif not are_same or final != value: + elif multivalue or final != value: final |= value - are_same = False - return list(final), not are_same + multivalue = True + return list(final), multivalue def set_value(self, value, multivalue=False): if multivalue: From 055e8250007731c3ac516ae7dbee7736629d393b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 15:41:33 +0800 Subject: [PATCH 208/429] bigroy comment on the camera setup on validation --- openpype/hosts/max/plugins/publish/collect_render.py | 3 +++ .../max/plugins/publish/validate_viewport_camera.py | 12 +++++------- .../deadline/plugins/publish/submit_max_deadline.py | 8 ++++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index db5c84fad9..7df71799d3 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -34,6 +34,9 @@ class CollectRender(pyblish.api.InstancePlugin): aovs = RenderProducts().get_aovs(instance.name) files_by_aov.update(aovs) + camera = rt.viewport.GetCamera() + instance.data["cameras"] = [camera.name] or None + if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["files"] = list() diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py index b35ba482a9..533b55f969 100644 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -13,8 +13,7 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Viewport Camera - Check if the renderable camera in scene used as viewport - camera for rendering + Check if the renderable camera used for rendering """ order = pyblish.api.ValidatorOrder @@ -27,11 +26,9 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, def process(self, instance): if not self.is_active(instance.data): return - cameras_in_scene = [c for c in rt.Objects - if rt.classOf(c) in rt.Camera.Classes] - if rt.viewport.getCamera() not in cameras_in_scene: + if not instance.data["cameras"]: raise PublishValidationError( - "Cameras in Scene not used as viewport camera" + "No renderable Camera found in scene." ) @classmethod @@ -39,10 +36,11 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, rt.viewport.setType(rt.Name("view_camera")) camera = rt.viewport.GetCamera() - cls.log.info(f"Camera {camera} set as viewport camera") + cls.log.info(f"Camera {camera} set as renderable camera") renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] if renderer == "Arnold": arv = rt.MAXToAOps.ArnoldRenderView() arv.setOption("Camera", str(camera)) arv.close() + instance.data["cameras"] = [camera.name] diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index d8725e853c..6b42270d05 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -31,6 +31,7 @@ class MaxPluginInfo(object): Version = attr.ib(default=None) # Mandatory for Deadline SaveFile = attr.ib(default=True) IgnoreInputs = attr.ib(default=True) + Camera = attr.ib(default=None) class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, @@ -154,7 +155,8 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, SceneFile=self.scene_path, Version=instance.data["maxversion"], SaveFile=True, - IgnoreInputs=True + IgnoreInputs=True, + Camera=instance.data["cameras"][0] ) plugin_payload = attr.asdict(plugin_info) @@ -238,7 +240,9 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, if renderer == "Redshift_Renderer": plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") - + if instance.data["cameras"]: + plugin_info["Camera0"] = None + plugin_info["Camera1"] = instance.data["cameras"][0] self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) From 72d7a9191baca344498f6c686c44adaf0d570f26 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:00:22 +0800 Subject: [PATCH 209/429] bug fix the camera instance data collection --- openpype/hosts/max/plugins/publish/collect_render.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 7df71799d3..e7730369b6 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -35,7 +35,9 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() - instance.data["cameras"] = [camera.name] or None + instance.data["cameras"] = None + if camera: + instance.data["cameras"] = [camera.name] if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() From b9fb4ce9e9669cfd6773fde30139243e77981a3c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:05:02 +0800 Subject: [PATCH 210/429] rename the validator --- .../publish/validate_renderable_camera.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 openpype/hosts/max/plugins/publish/validate_renderable_camera.py diff --git a/openpype/hosts/max/plugins/publish/validate_renderable_camera.py b/openpype/hosts/max/plugins/publish/validate_renderable_camera.py new file mode 100644 index 0000000000..61321661b5 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_renderable_camera.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin) +from openpype.pipeline.publish import RepairAction +from openpype.hosts.max.api.lib import get_current_renderer + +from pymxs import runtime as rt + + +class ValidateRenderableCamera(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Renderable Camera + + Check if the renderable camera used for rendering + """ + + order = pyblish.api.ValidatorOrder + families = ["maxrender"] + hosts = ["max"] + label = "Renderable Camera" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + if not instance.data["cameras"]: + raise PublishValidationError( + "No renderable Camera found in scene." + ) + + @classmethod + def repair(cls, instance): + + rt.viewport.setType(rt.Name("view_camera")) + camera = rt.viewport.GetCamera() + cls.log.info(f"Camera {camera} set as renderable camera") + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + if renderer == "Arnold": + arv = rt.MAXToAOps.ArnoldRenderView() + arv.setOption("Camera", str(camera)) + arv.close() + instance.data["cameras"] = [camera.name] From 4e88f705ee67af7ba1c0a6e920288759be006fd5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:12:36 +0800 Subject: [PATCH 211/429] remove the duplicated validator --- .../publish/validate_viewport_camera.py | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 openpype/hosts/max/plugins/publish/validate_viewport_camera.py diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py deleted file mode 100644 index 533b55f969..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from openpype.pipeline import ( - PublishValidationError, - OptionalPyblishPluginMixin) -from openpype.pipeline.publish import RepairAction -from openpype.hosts.max.api.lib import get_current_renderer - -from pymxs import runtime as rt - - -class ValidateViewportCamera(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validates Viewport Camera - - Check if the renderable camera used for rendering - """ - - order = pyblish.api.ValidatorOrder - families = ["maxrender"] - hosts = ["max"] - label = "Viewport Camera" - optional = True - actions = [RepairAction] - - def process(self, instance): - if not self.is_active(instance.data): - return - if not instance.data["cameras"]: - raise PublishValidationError( - "No renderable Camera found in scene." - ) - - @classmethod - def repair(cls, instance): - - rt.viewport.setType(rt.Name("view_camera")) - camera = rt.viewport.GetCamera() - cls.log.info(f"Camera {camera} set as renderable camera") - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - if renderer == "Arnold": - arv = rt.MAXToAOps.ArnoldRenderView() - arv.setOption("Camera", str(camera)) - arv.close() - instance.data["cameras"] = [camera.name] From 5685e2a1681d2ea74ac481bfa31de6f0a370d55f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:48:52 +0800 Subject: [PATCH 212/429] introduce imprint function for correct version in hda loader --- openpype/hosts/houdini/plugins/load/load_hda.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py index 57edc341a3..9630716253 100644 --- a/openpype/hosts/houdini/plugins/load/load_hda.py +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -59,6 +59,9 @@ class HdaLoader(load.LoaderPlugin): def_paths = [d.libraryFilePath() for d in defs] new = def_paths.index(file_path) defs[new].setIsPreferred(True) + hda_node.setParms({ + "representation": str(representation["_id"]) + }) def remove(self, container): node = container["node"] From f6c2be8b2a57559418077a68054e9ae3dfdbd76e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Aug 2023 11:15:37 +0200 Subject: [PATCH 213/429] force repaint --- openpype/tools/utils/multiselection_combobox.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/utils/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py index 27576a449a..13b396a059 100644 --- a/openpype/tools/utils/multiselection_combobox.py +++ b/openpype/tools/utils/multiselection_combobox.py @@ -266,12 +266,14 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def _update_size_hint(self): if self._custom_text is not None: self.update() + self.repaint() return self._lines = {} items = self.checked_items_text() if not items: self.update() + self.repaint() return option = QtWidgets.QStyleOptionComboBox() @@ -311,6 +313,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.update() self.updateGeometry() + self.repaint() def sizeHint(self): value = super(MultiSelectionComboBox, self).sizeHint() From b412e221646b56b57a911f9bab312756d360f640 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Aug 2023 11:17:25 +0200 Subject: [PATCH 214/429] remove multiselection combobox from settings --- .../tools/settings/settings/item_widgets.py | 2 +- .../settings/multiselection_combobox.py | 356 ------------------ 2 files changed, 1 insertion(+), 357 deletions(-) delete mode 100644 openpype/tools/settings/settings/multiselection_combobox.py diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 117eca7d6b..2fd13cbbd8 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -4,6 +4,7 @@ from qtpy import QtWidgets, QtCore, QtGui from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET +from openpype.tools.utils import MultiSelectionComboBox from openpype.settings.entities.exceptions import BaseInvalidValue from .widgets import ( @@ -15,7 +16,6 @@ from .widgets import ( SettingsNiceCheckbox, SettingsLineEdit ) -from .multiselection_combobox import MultiSelectionComboBox from .wrapper_widgets import ( WrapperWidget, CollapsibleWrapper, diff --git a/openpype/tools/settings/settings/multiselection_combobox.py b/openpype/tools/settings/settings/multiselection_combobox.py deleted file mode 100644 index d64fc83745..0000000000 --- a/openpype/tools/settings/settings/multiselection_combobox.py +++ /dev/null @@ -1,356 +0,0 @@ -from qtpy import QtCore, QtGui, QtWidgets -from openpype.tools.utils.lib import ( - checkstate_int_to_enum, - checkstate_enum_to_int, -) -from openpype.tools.utils.constants import ( - CHECKED_INT, - UNCHECKED_INT, - ITEM_IS_USER_TRISTATE, -) - - -class ComboItemDelegate(QtWidgets.QStyledItemDelegate): - """ - Helper styled delegate (mostly based on existing private Qt's - delegate used by the QtWidgets.QComboBox). Used to style the popup like a - list view (e.g windows style). - """ - - def paint(self, painter, option, index): - option = QtWidgets.QStyleOptionViewItem(option) - option.showDecorationSelected = True - - # option.state &= ( - # ~QtWidgets.QStyle.State_HasFocus - # & ~QtWidgets.QStyle.State_MouseOver - # ) - super(ComboItemDelegate, self).paint(painter, option, index) - - -class MultiSelectionComboBox(QtWidgets.QComboBox): - value_changed = QtCore.Signal() - focused_in = QtCore.Signal() - - ignored_keys = { - QtCore.Qt.Key_Up, - QtCore.Qt.Key_Down, - QtCore.Qt.Key_PageDown, - QtCore.Qt.Key_PageUp, - QtCore.Qt.Key_Home, - QtCore.Qt.Key_End, - } - - top_bottom_padding = 2 - left_right_padding = 3 - left_offset = 4 - top_bottom_margins = 2 - item_spacing = 5 - - item_bg_color = QtGui.QColor("#31424e") - - def __init__( - self, parent=None, placeholder="", separator=", ", **kwargs - ): - super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) - self.setObjectName("MultiSelectionComboBox") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - self._popup_is_shown = False - self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) - self._initial_mouse_pos = None - self._separator = separator - self.placeholder_text = placeholder - self.delegate = ComboItemDelegate(self) - self.setItemDelegate(self.delegate) - - self.lines = {} - self.item_height = None - - def focusInEvent(self, event): - self.focused_in.emit() - return super(MultiSelectionComboBox, self).focusInEvent(event) - - def mousePressEvent(self, event): - """Reimplemented.""" - self._popup_is_shown = False - super(MultiSelectionComboBox, self).mousePressEvent(event) - if self._popup_is_shown: - self._initial_mouse_pos = self.mapToGlobal(event.pos()) - self._block_mouse_release_timer.start( - QtWidgets.QApplication.doubleClickInterval() - ) - - def showPopup(self): - """Reimplemented.""" - super(MultiSelectionComboBox, self).showPopup() - view = self.view() - view.installEventFilter(self) - view.viewport().installEventFilter(self) - self._popup_is_shown = True - - def hidePopup(self): - """Reimplemented.""" - self.view().removeEventFilter(self) - self.view().viewport().removeEventFilter(self) - self._popup_is_shown = False - self._initial_mouse_pos = None - super(MultiSelectionComboBox, self).hidePopup() - self.view().clearFocus() - - def _event_popup_shown(self, obj, event): - if not self._popup_is_shown: - return - - current_index = self.view().currentIndex() - model = self.model() - - if event.type() == QtCore.QEvent.MouseMove: - if ( - self.view().isVisible() - and self._initial_mouse_pos is not None - and self._block_mouse_release_timer.isActive() - ): - diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos - if diff.manhattanLength() > 9: - self._block_mouse_release_timer.stop() - return - - index_flags = current_index.flags() - state = checkstate_int_to_enum( - current_index.data(QtCore.Qt.CheckStateRole) - ) - new_state = None - - if event.type() == QtCore.QEvent.MouseButtonRelease: - if ( - self._block_mouse_release_timer.isActive() - or not current_index.isValid() - or not self.view().isVisible() - or not self.view().rect().contains(event.pos()) - or not index_flags & QtCore.Qt.ItemIsSelectable - or not index_flags & QtCore.Qt.ItemIsEnabled - or not index_flags & QtCore.Qt.ItemIsUserCheckable - ): - return - - if state == QtCore.Qt.Unchecked: - new_state = CHECKED_INT - else: - new_state = UNCHECKED_INT - - elif event.type() == QtCore.QEvent.KeyPress: - # TODO: handle QtCore.Qt.Key_Enter, Key_Return? - if event.key() == QtCore.Qt.Key_Space: - if ( - index_flags & QtCore.Qt.ItemIsUserCheckable - and index_flags & ITEM_IS_USER_TRISTATE - ): - new_state = (checkstate_enum_to_int(state) + 1) % 3 - - elif index_flags & QtCore.Qt.ItemIsUserCheckable: - # toggle the current items check state - if state != QtCore.Qt.Checked: - new_state = CHECKED_INT - else: - new_state = UNCHECKED_INT - - if new_state is not None: - model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) - self.view().update(current_index) - self.update_size_hint() - self.value_changed.emit() - return True - - def eventFilter(self, obj, event): - """Reimplemented.""" - result = self._event_popup_shown(obj, event) - if result is not None: - return result - - return super(MultiSelectionComboBox, self).eventFilter(obj, event) - - def addItem(self, *args, **kwargs): - idx = self.count() - super(MultiSelectionComboBox, self).addItem(*args, **kwargs) - self.model().item(idx).setCheckable(True) - - def paintEvent(self, event): - """Reimplemented.""" - painter = QtWidgets.QStylePainter(self) - option = QtWidgets.QStyleOptionComboBox() - self.initStyleOption(option) - painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) - - # draw the icon and text - items = self.checked_items_text() - if not items: - option.currentText = self.placeholder_text - option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) - painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) - return - - font_metricts = self.fontMetrics() - - if self.item_height is None: - self.updateGeometry() - self.update() - return - - for line, items in self.lines.items(): - top_y = ( - option.rect.top() - + (line * self.item_height) - + self.top_bottom_margins - ) - left_x = option.rect.left() + self.left_offset - for item in items: - label_rect = font_metricts.boundingRect(item) - label_height = label_rect.height() - - label_rect.moveTop(top_y) - label_rect.moveLeft(left_x) - label_rect.setHeight(self.item_height) - label_rect.setWidth( - label_rect.width() + self.left_right_padding - ) - - bg_rect = QtCore.QRectF(label_rect) - bg_rect.setWidth( - label_rect.width() + self.left_right_padding - ) - left_x = bg_rect.right() + self.item_spacing - - label_rect.moveLeft(label_rect.x() + self.left_right_padding) - - bg_rect.setHeight(label_height + (2 * self.top_bottom_padding)) - bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) - - path = QtGui.QPainterPath() - path.addRoundedRect(bg_rect, 5, 5) - - painter.fillPath(path, self.item_bg_color) - - painter.drawText( - label_rect, - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, - item - ) - - def resizeEvent(self, *args, **kwargs): - super(MultiSelectionComboBox, self).resizeEvent(*args, **kwargs) - self.update_size_hint() - - def update_size_hint(self): - self.lines = {} - - items = self.checked_items_text() - if not items: - self.update() - return - - option = QtWidgets.QStyleOptionComboBox() - self.initStyleOption(option) - btn_rect = self.style().subControlRect( - QtWidgets.QStyle.CC_ComboBox, - option, - QtWidgets.QStyle.SC_ComboBoxArrow - ) - total_width = option.rect.width() - btn_rect.width() - - line = 0 - self.lines = {line: []} - - font_metricts = self.fontMetrics() - default_left_x = 0 + self.left_offset - left_x = int(default_left_x) - for item in items: - rect = font_metricts.boundingRect(item) - width = rect.width() + (2 * self.left_right_padding) - right_x = left_x + width - if right_x > total_width: - left_x = int(default_left_x) - if self.lines.get(line): - line += 1 - self.lines[line] = [item] - left_x += width - else: - self.lines[line] = [item] - line += 1 - else: - if line in self.lines: - self.lines[line].append(item) - else: - self.lines[line] = [item] - left_x = left_x + width + self.item_spacing - - self.update() - self.updateGeometry() - - def sizeHint(self): - value = super(MultiSelectionComboBox, self).sizeHint() - lines = len(self.lines) - if lines == 0: - lines = 1 - - if self.item_height is None: - self.item_height = ( - self.fontMetrics().height() - + (2 * self.top_bottom_padding) - + (2 * self.top_bottom_margins) - ) - value.setHeight( - (lines * self.item_height) - + (2 * self.top_bottom_margins) - ) - return value - - def setItemCheckState(self, index, state): - self.setItemData(index, state, QtCore.Qt.CheckStateRole) - - def set_value(self, values): - for idx in range(self.count()): - value = self.itemData(idx, role=QtCore.Qt.UserRole) - if value in values: - check_state = CHECKED_INT - else: - check_state = UNCHECKED_INT - self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) - self.update_size_hint() - - def value(self): - items = list() - for idx in range(self.count()): - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - if state == QtCore.Qt.Checked: - items.append( - self.itemData(idx, role=QtCore.Qt.UserRole) - ) - return items - - def checked_items_text(self): - items = list() - for idx in range(self.count()): - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - if state == QtCore.Qt.Checked: - items.append(self.itemText(idx)) - return items - - def wheelEvent(self, event): - event.ignore() - - def keyPressEvent(self, event): - if ( - event.key() == QtCore.Qt.Key_Down - and event.modifiers() & QtCore.Qt.AltModifier - ): - return self.showPopup() - - if event.key() in self.ignored_keys: - return event.ignore() - - return super(MultiSelectionComboBox, self).keyPressEvent(event) From cbc622c46f52fb784c6dc8c49d37c3b3d65f0c85 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Aug 2023 11:23:38 +0200 Subject: [PATCH 215/429] update docstrings --- openpype/lib/attribute_definitions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 44aa8dee48..a71709cace 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -427,9 +427,12 @@ class EnumDef(AbstractAttrDef): """Enumeration of single item from items. Args: - items: Items definition that can be converted using - 'prepare_enum_items'. - default: Default value. Must be one key(value) from passed items. + items (Union[list[str], list[dict[str, Any]]): Items definition that + can be converted using 'prepare_enum_items'. + default (Optional[Any]): Default value. Must be one key(value) from + passed items or list of values for multiselection. + multiselection (Optional[bool]): If True, multiselection is allowed. + Output is list of selected items. """ type = "enum" From 84e89aa422728daaac3f2550358cdc18d97a89f7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Aug 2023 11:39:07 +0200 Subject: [PATCH 216/429] Webpublisher: better encode list values for click (#5546) * Fix - list of arguments must be provided differently Targets could be a list, original implementation pushed it as a comma separated, it must be separated by space for click to understand. * Fix - split by space targets might come as a tuple from command line * Fix - better providing of arguments * Revert "Fix - split by space" This reverts commit 23884ac1c544e2d8003d82423a0da8b83821d426. --- .../webserver_service/webpublish_routes.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index e56f245d27..20d585e906 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -280,13 +280,14 @@ class BatchPublishEndpoint(WebpublishApiEndpoint): for key, value in add_args.items(): # Skip key values where value is None - if value is not None: - args.append("--{}".format(key)) - # Extend list into arguments (targets can be a list) - if isinstance(value, (tuple, list)): - args.extend(value) - else: - args.append(value) + if value is None: + continue + arg_key = "--{}".format(key) + if not isinstance(value, (tuple, list)): + value = [value] + + for item in value: + args += [arg_key, item] log.info("args:: {}".format(args)) if add_to_queue: From d3c1b84835aed4579ef7909e52631ab0732d6796 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 12:07:23 +0200 Subject: [PATCH 217/429] fixing paths slashes in nuke api --- openpype/hosts/nuke/api/lib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 2a6c1fb12c..d6a5e67ba0 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2107,8 +2107,9 @@ class WorkfileSettings(object): # set ocio config path if config_data: + config_path = config_data["path"].replace("\\", "/") log.info("OCIO config path found: `{}`".format( - config_data["path"])) + config_path)) # check if there's a mismatch between environment and settings correct_settings = self._is_settings_matching_environment( @@ -2233,7 +2234,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. Returns: str: OCIO config path with environment variable TCL expression """ - config_path = config_data["path"] + config_path = config_data["path"].replace("\\", "/") config_template = config_data["template"] included_vars = self._get_included_vars(config_template) From e9c3a84916a87ef8df717b4f75b698f55b7cd5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 31 Aug 2023 12:11:22 +0200 Subject: [PATCH 218/429] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/api/lib.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index cedbe6d5e6..157300d150 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3371,10 +3371,9 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): Returns: str: viewer config string """ - if display: - if path_like: - return "{}/{}".format(display, viewer) - else: - return "{} ({})".format(viewer, display) - else: + if not display: return viewer + + if path_like: + return "{}/{}".format(display, viewer) + return "{} ({})".format(viewer, display) From 33e18661241a59a612a06558fbc19f5de264220b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 13:30:33 +0200 Subject: [PATCH 219/429] separating code into own function for monitor lut settings --- openpype/hosts/nuke/api/lib.py | 64 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 157300d150..95e945057c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2092,30 +2092,14 @@ class WorkfileSettings(object): workfile_settings.pop("colorManagement", None) workfile_settings.pop("OCIO_config", None) - # treat monitor lut separately + # get monitor lut from settings respecting Nuke version differences monitor_lut = workfile_settings.pop("monitorLut", None) - m_display, m_viewer = get_viewer_config_from_string(monitor_lut) - v_display, v_viewer = get_viewer_config_from_string( - viewer_process_settings - ) + monitor_lut_data = self._get_monitor_settings( + viewer_process_settings, monitor_lut) - # set monitor lut differently for nuke version 14 - if nuke.NUKE_VERSION_MAJOR >= 14: - workfile_settings["monitorOutLUT"] = create_viewer_profile_string( - m_viewer, m_display, path_like=False) - # monitorLut=thumbnails - viewerProcess makes more sense - workfile_settings["monitorLut"] = create_viewer_profile_string( - v_viewer, v_display, path_like=False) - - if nuke.NUKE_VERSION_MAJOR == 13: - workfile_settings["monitorOutLUT"] = create_viewer_profile_string( - m_viewer, m_display, path_like=False) - # monitorLut=thumbnails - viewerProcess makes more sense - workfile_settings["monitorLut"] = create_viewer_profile_string( - v_viewer, v_display, path_like=True) - if nuke.NUKE_VERSION_MAJOR <= 12: - workfile_settings["monitorLut"] = create_viewer_profile_string( - m_viewer, m_display, path_like=True) + # set monitor related knobs luts (MonitorOut, Thumbnails) + for knob, value_ in monitor_lut_data.items(): + workfile_settings[knob] = value_ # then set the rest for knob, value_ in workfile_settings.items(): @@ -2144,6 +2128,42 @@ class WorkfileSettings(object): if correct_settings: self._set_ocio_config_path_to_workfile(config_data) + def _get_monitor_settings(self, viewer_lut, monitor_lut): + """ Get monitor settings from viewer and monitor lut + + Args: + viewer_lut (str): viewer lut string + monitor_lut (str): monitor lut string + + Returns: + dict: monitor settings + """ + output_data = {} + m_display, m_viewer = get_viewer_config_from_string(monitor_lut) + v_display, v_viewer = get_viewer_config_from_string( + viewer_lut + ) + + # set monitor lut differently for nuke version 14 + if nuke.NUKE_VERSION_MAJOR >= 14: + output_data["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + output_data["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=False) + + if nuke.NUKE_VERSION_MAJOR == 13: + output_data["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + output_data["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=True) + if nuke.NUKE_VERSION_MAJOR <= 12: + output_data["monitorLut"] = create_viewer_profile_string( + m_viewer, m_display, path_like=True) + + return output_data + def _is_settings_matching_environment(self, config_data): """ Check if OCIO config path is different from environment From 82b50dcfc2698e60b1ffb45723a4112a0865f8e4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 14:00:13 +0200 Subject: [PATCH 220/429] adding comments --- .../plugins/publish/validate_rendered_frames.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index ef3d4d0bb5..e316c6ff6e 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -17,18 +17,23 @@ class RepairActionBase(pyblish.api.Action): def repair_knob(self, context, instances, state): create_context = context.data["create_context"] for instance in instances: - files_remove = [ + files_to_remove = [ + # create full path to file os.path.join(instance.data["outputDir"], f_) + # iterate representations from instance data for r_ in instance.data.get("representations", []) + # make sure that the representation has files in list + if r_.get("files") and isinstance(r_.get("files"), list) + # iterate files from representation files list for f_ in r_.get("files", []) ] - self.log.info("Files to be removed: {}".format(files_remove)) - for f_ in files_remove: + self.log.info("Files to be removed: {}".format(files_to_remove)) + for f_ in files_to_remove: os.remove(f_) self.log.debug("removing file: {}".format(f_)) # Reset the render knob - instance_id = instance.data["instance_id"] + instance_id = instance.data.get("instance_id") created_instance = create_context.get_instance_by_id( instance_id ) From 1a3c8ad77252ffa9217c6af98ba27dcdd56c366a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 31 Aug 2023 17:34:42 +0300 Subject: [PATCH 221/429] make validateReviewColorspace optional --- .../plugins/publish/validate_review_colorspace.py | 10 ++++++++-- .../defaults/project_settings/houdini.json | 5 +++++ .../schemas/schema_houdini_publish.json | 6 +++++- .../houdini/server/settings/publish_plugins.py | 14 +++++++++++--- server_addon/houdini/server/version.py | 2 +- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 2c7420bf48..f680f62142 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- import pyblish.api -from openpype.pipeline import PublishValidationError +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa @@ -14,7 +17,8 @@ class SetDefaultViewSpaceAction(RepairAction): icon = "mdi.monitor" -class ValidateReviewColorspace(pyblish.api.InstancePlugin): +class ValidateReviewColorspace(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validate Review Colorspace parameters. It checks if 'OCIO Colorspace' parameter was set to valid value. @@ -26,6 +30,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): label = "Validate Review Colorspace" actions = [SetDefaultViewSpaceAction, SelectROPAction] + optional = True + def process(self, instance): invalid = self.get_invalid(instance) if invalid: diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 9d047c28bd..93d5c50d5e 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -93,6 +93,11 @@ "$JOB" ] }, + "ValidateReviewColorspace": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateContainers": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index aa6eaf5164..b57089007e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -40,6 +40,10 @@ "type": "schema_template", "name": "template_publish_plugin", "template_data": [ + { + "key": "ValidateReviewColorspace", + "label": "Validate Review Colorspace" + }, { "key": "ValidateContainers", "label": "ValidateContainers" @@ -47,4 +51,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 7d35d7e634..4534d8d0d9 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -120,7 +120,7 @@ class ValidateWorkfilePathsModel(BaseSettingsModel): ) -class ValidateContainersModel(BaseSettingsModel): +class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") active: bool = Field(title="Active") @@ -130,8 +130,11 @@ class PublishPluginsModel(BaseSettingsModel): ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( default_factory=ValidateWorkfilePathsModel, title="Validate workfile paths settings.") - ValidateContainers: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateReviewColorspace: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Review Colorspace.") + ValidateContainers: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Latest Containers.") @@ -148,6 +151,11 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "$JOB" ] }, + "ValidateReviewColorspace": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateContainers": { "enabled": True, "optional": True, diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From 4d94bec464349db6e5277ae9d9615f15bf73e991 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 22:55:59 +0800 Subject: [PATCH 222/429] oscar's comment on cameras implemenetation --- openpype/hosts/max/plugins/publish/collect_render.py | 4 +--- .../modules/deadline/plugins/publish/submit_max_deadline.py | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index e7730369b6..8ee2f43103 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -35,9 +35,7 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() - instance.data["cameras"] = None - if camera: - instance.data["cameras"] = [camera.name] + instance.data["cameras"] = [camera.name] if camera else None # noqa if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 6b42270d05..01132662d4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -31,7 +31,6 @@ class MaxPluginInfo(object): Version = attr.ib(default=None) # Mandatory for Deadline SaveFile = attr.ib(default=True) IgnoreInputs = attr.ib(default=True) - Camera = attr.ib(default=None) class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, @@ -155,8 +154,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, SceneFile=self.scene_path, Version=instance.data["maxversion"], SaveFile=True, - IgnoreInputs=True, - Camera=instance.data["cameras"][0] + IgnoreInputs=True ) plugin_payload = attr.asdict(plugin_info) @@ -242,6 +240,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "separateAovFiles") if instance.data["cameras"]: plugin_info["Camera0"] = None + plugin_info["Camera"] = instance.data["cameras"][0] plugin_info["Camera1"] = instance.data["cameras"][0] self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) From e2e3bb3a68309c13e9f53fb5713baea6df6704dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:23:38 +0200 Subject: [PATCH 223/429] AYON: Fill entities during editorial (2) (#5549) * fix appending of instances by asset name * skip disabled instances * formatting fix --- openpype/plugins/publish/extract_hierarchy_to_ayon.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index de9a70c233..36a7042ba5 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -42,13 +42,16 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): def _fill_instance_entities(self, context, project_name): instances_by_asset_name = collections.defaultdict(list) for instance in context: + if instance.data.get("publish") is False: + continue + instance_entity = instance.data.get("assetEntity") if instance_entity: continue # Skip if instance asset does not match instance_asset_name = instance.data.get("asset") - instances_by_asset_name[instance_asset_name] = instance + instances_by_asset_name[instance_asset_name].append(instance) project_doc = context.data["projectEntity"] asset_docs = get_assets( From cf8919dbc5336e29e121020a7506d70277386834 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:24:52 +0200 Subject: [PATCH 224/429] AYON: Update settings (#5544) * added 3dsmax settings conversion * added conversion of reposition knobs * fixed matching defaults * updated core settings * updated aftereffects settings model --- openpype/settings/ayon_settings.py | 29 ++++++++++ .../server/settings/creator_plugins.py | 2 +- .../aftereffects/server/settings/main.py | 2 +- server_addon/aftereffects/server/version.py | 2 +- server_addon/core/server/settings/main.py | 53 +++++++++++++++++-- server_addon/core/server/version.py | 2 +- .../max/server/settings/render_settings.py | 2 +- server_addon/maya/server/settings/creators.py | 4 +- .../server/settings/simple_creators.py | 17 ++++++ 9 files changed, 104 insertions(+), 9 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 50abfe4839..9a4f0607e0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -616,6 +616,23 @@ def _convert_maya_project_settings(ayon_settings, output): output["maya"] = ayon_maya +def _convert_3dsmax_project_settings(ayon_settings, output): + if "max" not in ayon_settings: + return + + ayon_max = ayon_settings["max"] + _convert_host_imageio(ayon_max) + if "PointCloud" in ayon_max: + point_cloud_attribute = ayon_max["PointCloud"]["attribute"] + new_point_cloud_attribute = { + item["name"]: item["value"] + for item in point_cloud_attribute + } + ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + + output["max"] = ayon_max + + def _convert_nuke_knobs(knobs): new_knobs = [] for knob in knobs: @@ -737,6 +754,17 @@ def _convert_nuke_project_settings(ayon_settings, output): item_filter["subsets"] = item_filter.pop("product_names") item_filter["families"] = item_filter.pop("product_types") + reformat_nodes_config = item.get("reformat_nodes_config") or {} + reposition_nodes = reformat_nodes_config.get( + "reposition_nodes") or [] + + for reposition_node in reposition_nodes: + if "knobs" not in reposition_node: + continue + reposition_node["knobs"] = _convert_nuke_knobs( + reposition_node["knobs"] + ) + name = item.pop("name") new_review_data_outputs[name] = item ayon_publish["ExtractReviewDataMov"]["outputs"] = new_review_data_outputs @@ -1261,6 +1289,7 @@ def convert_project_settings(ayon_settings, default_settings): _convert_flame_project_settings(ayon_settings, output) _convert_fusion_project_settings(ayon_settings, output) _convert_maya_project_settings(ayon_settings, output) + _convert_3dsmax_project_settings(ayon_settings, output) _convert_nuke_project_settings(ayon_settings, output) _convert_hiero_project_settings(ayon_settings, output) _convert_photoshop_project_settings(ayon_settings, output) diff --git a/server_addon/aftereffects/server/settings/creator_plugins.py b/server_addon/aftereffects/server/settings/creator_plugins.py index ee52fadd40..9cb03b0b26 100644 --- a/server_addon/aftereffects/server/settings/creator_plugins.py +++ b/server_addon/aftereffects/server/settings/creator_plugins.py @@ -5,7 +5,7 @@ from ayon_server.settings import BaseSettingsModel class CreateRenderPlugin(BaseSettingsModel): mark_for_review: bool = Field(True, title="Review") - defaults: list[str] = Field( + default_variants: list[str] = Field( default_factory=list, title="Default Variants" ) diff --git a/server_addon/aftereffects/server/settings/main.py b/server_addon/aftereffects/server/settings/main.py index 04d2e51cc9..4edc46d259 100644 --- a/server_addon/aftereffects/server/settings/main.py +++ b/server_addon/aftereffects/server/settings/main.py @@ -40,7 +40,7 @@ DEFAULT_AFTEREFFECTS_SETTING = { "create": { "RenderCreator": { "mark_for_review": True, - "defaults": [ + "default_variants": [ "Main" ] } diff --git a/server_addon/aftereffects/server/version.py b/server_addon/aftereffects/server/version.py index a242f0e757..df0c92f1e2 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.1" +__version__ = "0.1.2" diff --git a/server_addon/core/server/settings/main.py b/server_addon/core/server/settings/main.py index d19d732e71..ca8f7e63ed 100644 --- a/server_addon/core/server/settings/main.py +++ b/server_addon/core/server/settings/main.py @@ -4,6 +4,7 @@ from ayon_server.settings import ( BaseSettingsModel, MultiplatformPathListModel, ensure_unique_names, + task_types_enum, ) from ayon_server.exceptions import BadRequestException @@ -38,13 +39,52 @@ class CoreImageIOConfigModel(BaseSettingsModel): class CoreImageIOBaseModel(BaseSettingsModel): activate_global_color_management: bool = Field( False, - title="Override global OCIO config" + title="Enable Color Management" ) ocio_config: CoreImageIOConfigModel = Field( - default_factory=CoreImageIOConfigModel, title="OCIO config" + default_factory=CoreImageIOConfigModel, + title="OCIO config" ) file_rules: CoreImageIOFileRulesModel = Field( - default_factory=CoreImageIOFileRulesModel, title="File Rules" + default_factory=CoreImageIOFileRulesModel, + title="File Rules" + ) + + +class VersionStartCategoryProfileModel(BaseSettingsModel): + _layout = "expanded" + host_names: list[str] = Field( + default_factory=list, + title="Host names" + ) + 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" + ) + product_types: list[str] = Field( + default_factory=list, + title="Product types" + ) + product_names: list[str] = Field( + default_factory=list, + title="Product names" + ) + version_start: int = Field( + 1, + title="Version Start", + ge=0 + ) + + +class VersionStartCategoryModel(BaseSettingsModel): + profiles: list[VersionStartCategoryProfileModel] = Field( + default_factory=list, + title="Profiles" ) @@ -61,6 +101,10 @@ class CoreSettings(BaseSettingsModel): default_factory=GlobalToolsModel, title="Tools" ) + version_start_category: VersionStartCategoryModel = Field( + default_factory=VersionStartCategoryModel, + title="Version start" + ) imageio: CoreImageIOBaseModel = Field( default_factory=CoreImageIOBaseModel, title="Color Management (ImageIO)" @@ -131,6 +175,9 @@ DEFAULT_VALUES = { "studio_code": "", "environments": "{}", "tools": DEFAULT_TOOLS_VALUES, + "version_start_category": { + "profiles": [] + }, "publish": DEFAULT_PUBLISH_VALUES, "project_folder_structure": json.dumps({ "__project_root__": { diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/max/server/settings/render_settings.py b/server_addon/max/server/settings/render_settings.py index 6c236d9f12..c00cb5e436 100644 --- a/server_addon/max/server/settings/render_settings.py +++ b/server_addon/max/server/settings/render_settings.py @@ -44,6 +44,6 @@ class RenderSettingsModel(BaseSettingsModel): DEFAULT_RENDER_SETTINGS = { "default_render_image_folder": "renders/3dsmax", "aov_separator": "underscore", - "image_format": "png", + "image_format": "exr", "multipass": True } diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 9b97b92e59..11e2b8a36c 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -252,7 +252,9 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateUnrealSkeletalMesh": { "enabled": True, - "default_variants": [], + "default_variants": [ + "Main", + ], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { diff --git a/server_addon/traypublisher/server/settings/simple_creators.py b/server_addon/traypublisher/server/settings/simple_creators.py index 94d6602738..8335b9d34e 100644 --- a/server_addon/traypublisher/server/settings/simple_creators.py +++ b/server_addon/traypublisher/server/settings/simple_creators.py @@ -288,5 +288,22 @@ DEFAULT_SIMPLE_CREATORS = [ "allow_multiple_items": True, "allow_version_control": False, "extensions": [] + }, + { + "product_type": "audio", + "identifier": "", + "label": "Audio ", + "icon": "fa5s.file-audio", + "default_variants": [ + "Main" + ], + "description": "Audio product", + "detailed_description": "Audio files for review or final delivery", + "allow_sequences": False, + "allow_multiple_items": False, + "allow_version_control": False, + "extensions": [ + ".wav" + ] } ] From 13d1b407199e7012aa309a6072a405a2d35cf657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 31 Aug 2023 17:38:47 +0200 Subject: [PATCH 225/429] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/nuke/api/lib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 95e945057c..91a9294740 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2140,9 +2140,7 @@ class WorkfileSettings(object): """ output_data = {} m_display, m_viewer = get_viewer_config_from_string(monitor_lut) - v_display, v_viewer = get_viewer_config_from_string( - viewer_lut - ) + v_display, v_viewer = get_viewer_config_from_string(viewer_lut) # set monitor lut differently for nuke version 14 if nuke.NUKE_VERSION_MAJOR >= 14: From 1a936c155985e804f0ddc01e14a4573c56bbb8e7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 31 Aug 2023 22:42:13 +0300 Subject: [PATCH 226/429] BigRoy's comments --- .../houdini/plugins/publish/validate_review_colorspace.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index f680f62142..3f5e5bc354 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -13,7 +13,7 @@ import hou class SetDefaultViewSpaceAction(RepairAction): - label = "Set default view space" + label = "Set default view colorspace" icon = "mdi.monitor" @@ -33,6 +33,10 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, optional = True def process(self, instance): + + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( From 24cbaefe953589b39f681bf4b93cc992cbb9fa19 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 22:29:18 +0200 Subject: [PATCH 227/429] removing redundant action it was residual approach from old workflow --- .../plugins/publish/validate_rendered_frames.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index e316c6ff6e..9a35b61a0e 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -17,29 +17,14 @@ class RepairActionBase(pyblish.api.Action): def repair_knob(self, context, instances, state): create_context = context.data["create_context"] for instance in instances: - files_to_remove = [ - # create full path to file - os.path.join(instance.data["outputDir"], f_) - # iterate representations from instance data - for r_ in instance.data.get("representations", []) - # make sure that the representation has files in list - if r_.get("files") and isinstance(r_.get("files"), list) - # iterate files from representation files list - for f_ in r_.get("files", []) - ] - self.log.info("Files to be removed: {}".format(files_to_remove)) - for f_ in files_to_remove: - os.remove(f_) - self.log.debug("removing file: {}".format(f_)) - # Reset the render knob instance_id = instance.data.get("instance_id") created_instance = create_context.get_instance_by_id( instance_id ) created_instance.creator_attributes["render_target"] = state - self.log.info("Rendering toggled to `{}`".format(state)) + create_context.save_changes() From ec8a0f49480c8359f6b501f73626d770c12fe931 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 22:44:36 +0200 Subject: [PATCH 228/429] simplification of spaghetti code and improving logic --- .../plugins/publish/collect_farm_target.py | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/openpype/plugins/publish/collect_farm_target.py b/openpype/plugins/publish/collect_farm_target.py index 78410835dd..adcd842b48 100644 --- a/openpype/plugins/publish/collect_farm_target.py +++ b/openpype/plugins/publish/collect_farm_target.py @@ -15,31 +15,21 @@ class CollectFarmTarget(pyblish.api.InstancePlugin): return context = instance.context - try: - deadline_module = context.data.get("openPypeModules")["deadline"] - if deadline_module.enabled: - instance.data["toBeRenderedOn"] = "deadline" - self.log.debug("Collected render target: deadline") - except AttributeError: - self.log.error("Cannot get OpenPype Deadline module.") - raise AssertionError("OpenPype Deadline module not found.") - try: - royalrender_module = \ - context.data.get("openPypeModules")["royalrender"] - if royalrender_module.enabled: - instance.data["toBeRenderedOn"] = "royalrender" - self.log.debug("Collected render target: royalrender") + farm_name = "" + op_modules = context.data.get("openPypeModules") - except AttributeError: - self.log.error("Cannot get OpenPype RoyalRender module.") - raise AssertionError("OpenPype RoyalRender module not found.") + for farm_renderer in ["deadline", "royalrender", "muster"]: + op_module = op_modules.get(farm_renderer, False) - try: - muster_module = context.data.get("openPypeModules")["muster"] - if muster_module.enabled: - instance.data["toBeRenderedOn"] = "muster" - self.log.debug("Collected render target: muster") - except AttributeError: - self.log.error("Cannot get OpenPype Muster module.") - raise AssertionError("OpenPype Muster module not found.") + if op_module and op_module.enabled: + farm_name = farm_renderer + elif not op_module: + self.log.error("Cannot get OpenPype {0} module.".format( + farm_renderer)) + + if farm_name: + self.log.debug("Collected render target: {0}".format(farm_name)) + instance.data["toBeRenderedOn"] = farm_name + else: + AssertionError("No OpenPype renderer module found") From d4393bad6b466438a839786903152fc36e293cae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 23:18:52 +0200 Subject: [PATCH 229/429] changing signature since we only need context.data for input. --- openpype/pipeline/colorspace.py | 39 ++++++-------------- openpype/pipeline/publish/publish_plugins.py | 8 +--- 2 files changed, 13 insertions(+), 34 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 649d355f62..b7728936b0 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -617,7 +617,7 @@ def get_colorspace_settings_from_publish_context(context_data): Returns: tuple | bool: config, file rules or None """ - if "imageioSettings" in context_data: + if "imageioSettings" in context_data and context_data["imageioSettings"]: return context_data["imageioSettings"] project_name = context_data["projectName"] @@ -631,14 +631,13 @@ def get_colorspace_settings_from_publish_context(context_data): anatomy_data=anatomy_data ) - # in case host color management is not enabled - if not config_data: - return None - - file_rules = get_imageio_file_rules( - project_name, host_name, - project_settings=project_settings_ - ) + # caching invalid state, so it's not recalculated all the time + file_rules = None + if config_data: + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings_ + ) # caching settings for future instance processing context_data["imageioSettings"] = (config_data, file_rules) @@ -649,7 +648,6 @@ def get_colorspace_settings_from_publish_context(context_data): def set_colorspace_data_to_representation( representation, context_data, colorspace=None, - colorspace_settings=None, log=None ): """Sets colorspace data to representation. @@ -657,12 +655,7 @@ def set_colorspace_data_to_representation( Args: representation (dict): publishing representation context_data (publish.Context.data): publishing context data - config_data (dict): host resolved config data - file_rules (dict): host resolved file rules data colorspace (str, optional): colorspace name. Defaults to None. - colorspace_settings (tuple[dict, dict], optional): - Settings for config_data and file_rules. - Defaults to None. log (logging.Logger, optional): logger instance. Defaults to None. Example: @@ -691,21 +684,13 @@ def set_colorspace_data_to_representation( ) return - if colorspace_settings is None: - colorspace_settings = get_colorspace_settings_from_publish_context( - context_data) + # get colorspace settings + config_data, file_rules = get_colorspace_settings_from_publish_context( + context_data) # in case host color management is not enabled - if not colorspace_settings: - log.warning("Host's colorspace management is disabled.") - return - - # unpack colorspace settings - config_data, file_rules = colorspace_settings - if not config_data: - # warn in case no colorspace path was defined - log.warning("No colorspace management was defined") + log.warning("Host's colorspace management is disabled.") return log.debug("Config data is: `{}`".format(config_data)) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index 17ede069cb..ae6cbc42d1 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -319,19 +319,13 @@ class ColormanagedPyblishPluginMixin(object): def set_representation_colorspace( self, representation, context, colorspace=None, - colorspace_settings=None ): """Sets colorspace data to representation. Args: representation (dict): publishing representation context (publish.Context): publishing context - config_data (dict): host resolved config data - file_rules (dict): host resolved file rules data colorspace (str, optional): colorspace name. Defaults to None. - colorspace_settings (tuple[dict, dict], optional): - Settings for config_data and file_rules. - Defaults to None. Example: ``` @@ -348,10 +342,10 @@ class ColormanagedPyblishPluginMixin(object): ``` """ + # using cached settings if available set_colorspace_data_to_representation( representation, context.data, colorspace, - colorspace_settings, log=self.log ) From 1f057525dd6dd2f3d03fbb97f68ee8504ce9aae5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 23:28:12 +0200 Subject: [PATCH 230/429] testing: fixing zip file ID --- tests/unit/openpype/pipeline/test_colorspace.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index c22acee2d4..ac35a28303 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -28,10 +28,9 @@ class TestPipelineColorspace(TestPipeline): cd to OpenPype repo root dir poetry run python ./start.py runtests ../tests/unit/openpype/pipeline """ - TEST_FILES = [ ( - "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", + "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", "test_pipeline_colorspace.zip", "" ) From e4819fc952a8711e013c5e473933559219014483 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 01:02:32 +0300 Subject: [PATCH 231/429] Kayla's and BigRoy's comments --- .../create/create_unreal_staticmesh.py | 4 +- .../hosts/houdini/plugins/load/load_fbx.py | 58 ++++++++----------- .../plugins/publish/collect_fbx_type.py | 32 +--------- .../plugins/publish/collect_output_node.py | 7 ++- 4 files changed, 34 insertions(+), 67 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 179c81510a..a048965364 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -26,8 +26,8 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): """Unreal Static Meshes with collisions. """ # you should set - identifier = "io.openpype.creators.houdini.unrealstaticmesh" - label = "Unreal - Static Mesh" + identifier = "io.openpype.creators.houdini.unrealstaticmesh.fbx" + label = "Unreal - Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index d661f84eeb..2e4dafc2d8 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -31,7 +31,7 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): # get file path - file_path = self.get_file_path(context) + file_path = self.get_file_path(context=context) # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) @@ -41,10 +41,15 @@ class FbxLoader(load.LoaderPlugin): self[:] = nodes - # Call containerise function which does some - # automations for you - containerised_nodes = self.get_containerised_nodes( - nodes, context, node_name, namespace + # Call containerise function which does some automations for you + # like moving created nodes to the AVALON_CONTAINERS subnetwork + containerised_nodes = pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", ) return containerised_nodes @@ -61,8 +66,7 @@ class FbxLoader(load.LoaderPlugin): return # Update the file path - file_path = get_representation_path(representation) - file_path = self.format_path(file_path, representation) + file_path = self.get_file_path(representation=representation) file_node.setParms({"file": file_path}) @@ -77,15 +81,18 @@ class FbxLoader(load.LoaderPlugin): def switch(self, container, representation): self.update(container, representation) - def get_file_path(self, context): + def get_file_path(self, context=None, representation=None): """Return formatted file path.""" # Format file name, Houdini only wants forward slashes - file_path = self.filepath_from_context(context) - file_path = os.path.normpath(file_path) - file_path = file_path.replace("\\", "/") + if context: + file_path = self.filepath_from_context(context) + elif representation: + file_path = get_representation_path(representation) + else: + return "" - return file_path + return file_path.replace("\\", "/") def get_node_name(self, context, name=None, namespace=None): """Define node name.""" @@ -145,27 +152,8 @@ class FbxLoader(load.LoaderPlugin): # node to optimize "debug" displaying in the viewport. file_node.setDisplayFlag(True) - # Set new position for unpack node else it gets cluttered - nodes = [parent_node, file_node, attribdelete, null] - for nr, node in enumerate(nodes): - node.setPosition([0, (0 - nr)]) + # Set new position for children nodes + parent_node.layoutChildren() - return nodes - - def get_containerised_nodes(self, nodes, context, node_name, namespace): - """Call containerise function. - - It does some automations that you don't have to worry about, e.g. - 1. It moves created nodes to the AVALON_CONTAINERS subnetwork - 2. Add extra parameters - """ - containerised_nodes = pipeline.containerise( - node_name, - namespace, - nodes, - context, - self.__class__.__name__, - suffix="", - ) - - return containerised_nodes + # Retrun all the nodes + return [parent_node, file_node, attribdelete, null] diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 4c83829c67..6ac40a4f50 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -16,7 +16,7 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): """Collect data type for fbx instance.""" hosts = ["houdini"] - families = ["fbx", "staticMesh"] + families = ["staticMesh"] label = "Collect type of fbx" # Usually you will use this value as default @@ -25,35 +25,9 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # overrides InstancePlugin.process() def process(self, instance): - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh": # noqa: E501 + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 # such a condition can be used to differentiate between - # instances by identifier becuase sometimes instances + # instances by identifier because sometimes instances # may have the same family but different identifier # e.g. bgeo and alembic instance.data["families"] += ["fbx"] - - # Update instance.data with ouptut_node - out_node = self.get_output_node(instance) - - if out_node: - instance.data["output_node"] = out_node - - # Disclaimer : As a convntin we use collect_output_node.py - # to Update instance.data with ouptut_node of different types - # however, this collector is used for demonstration - - def get_output_node(self, instance): - """Getting output_node Logic.""" - - import hou - - # get output node - node = hou.node(instance.data["instance_node"]) - out_node = node.parm("startnode").evalAsNode() - - if not out_node: - self.log.warning("No output node collected.") - return - - self.log.debug("Output node: %s" % out_node.path()) - return out_node diff --git a/openpype/hosts/houdini/plugins/publish/collect_output_node.py b/openpype/hosts/houdini/plugins/publish/collect_output_node.py index 601ed17b39..91bd5fdb15 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/collect_output_node.py @@ -12,7 +12,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): "imagesequence", "usd", "usdrender", - "redshiftproxy" + "redshiftproxy", + "staticMesh" ] hosts = ["houdini"] @@ -57,6 +58,10 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): elif node_type == "Redshift_Proxy_Output": out_node = node.parm("RS_archive_sopPath").evalAsNode() + + elif node_type == "filmboxfbx": + out_node = node.parm("startnode").evalAsNode() + else: raise ValueError( "ROP node type '%s' is" " not supported." % node_type From 127c63eb94b98af297287833e81f0ec9b2a8c5e7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 01:04:11 +0300 Subject: [PATCH 232/429] hound comments --- openpype/hosts/houdini/plugins/load/load_fbx.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 2e4dafc2d8..681837e046 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -7,8 +7,6 @@ however this one includes extra comments for demonstration. This plugin is part of publish process guide. """ -import os - from openpype.pipeline import ( load, get_representation_path, From d1e82ceb7ed7e0681a9856b6376cc8831f1c2d2a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 15:00:45 +0300 Subject: [PATCH 233/429] allow publishing sop and obj nodes --- .../create/create_unreal_staticmesh.py | 70 ++++++------------- .../publish/validate_sop_output_node.py | 2 +- 2 files changed, 24 insertions(+), 48 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index a048965364..0b5b313d9e 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -73,6 +73,13 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): """Add settings for users. """ attrs = super().get_pre_create_attr_defs() + createsubnetroot = BoolDef("createsubnetroot", + tooltip="Create an extra root for the Export node " + "when it’s a subnetwork. This causes the " + "exporting subnetwork node to be " + "represented in the FBX file.", + default=False, + label="Create Root for Subnet") vcformat = EnumDef("vcformat", items={ 0: "Maya Compatible (MC)", @@ -88,7 +95,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): default=False, label="Convert Units") - return attrs + [vcformat, convert_units] + return attrs + [createsubnetroot, vcformat, convert_units] # Overrides BaseCreator.get_dynamic_data() def get_dynamic_data( @@ -123,13 +130,17 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): # 5. get Valid Frame Range trange = 1 + # 6. get createsubnetroot + createsubnetroot = pre_create_data.get("createsubnetroot") + # parms dictionary parms = { "startnode": selection, "sopoutput": output_path, "vcformat": vcformat, "convertunits": convertunits, - "trange": trange + "trange": trange, + "createsubnetroot": createsubnetroot } return parms @@ -149,36 +160,23 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): if self.selected_nodes: selected_node = self.selected_nodes[0] - # Although Houdini allows ObjNode path on `startnode` for the - # the ROP node we prefer it set to the SopNode path explicitly - - # Allow sop level paths (e.g. /obj/geo1/box1) + # Accept sop level nodes (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): selection = selected_node.path() self.log.debug( "Valid SopNode selection, 'Export' in filmboxfbx" - " will be set to '%s'." - % selected_node + " will be set to '%s'.", selected_node ) - # Allow object level paths to Geometry nodes (e.g. /obj/geo1) - # but do not allow other object level nodes types like cameras. - elif isinstance(selected_node, hou.ObjNode) and \ - selected_node.type().name() in ["geo"]: + # Accept object level nodes (e.g. /obj/geo1) + elif isinstance(selected_node, hou.ObjNode): + selection = selected_node.path() + self.log.debug( + "Valid ObjNode selection, 'Export' in filmboxfbx " + "will be set to the child path '%s'.", selection + ) - # get the output node with the minimum - # 'outputidx' or the node with display flag - sop_path = self.get_obj_output(selected_node) - - if sop_path: - selection = sop_path.path() - self.log.debug( - "Valid ObjNode selection, 'Export' in filmboxfbx " - "will be set to the child path '%s'." - % sop_path - ) - - if not selection: + else: self.log.debug( "Selection isn't valid. 'Export' in " "filmboxfbx will be empty." @@ -189,25 +187,3 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): ) return selection - - def get_obj_output(self, obj_node): - """Find output node with the smallest 'outputidx' - or return the node with the render flag instead. - """ - - outputs = obj_node.subnetOutputs() - - # if obj_node is empty - if not outputs: - return - - # if obj_node has one output child whether its - # sop output node or a node with the render flag - elif len(outputs) == 1: - return outputs[0] - - # if there are more than one, then it have multiple ouput nodes - # return the one with the minimum 'outputidx' - else: - return min(outputs, - key=lambda node: node.evalParm('outputidx')) diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index 2b426d96dd..d9dee38680 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -22,7 +22,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache", "fbx"] + families = ["pointcache", "vdbcache"] hosts = ["houdini"] label = "Validate Output Node" actions = [SelectROPAction, SelectInvalidAction] From 78b5ae149a89223cb2f5c8a59464510685a8d67a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 15:54:42 +0200 Subject: [PATCH 234/429] only project settings are passed by default to create plugin --- openpype/pipeline/create/context.py | 19 ++++++++++------- openpype/pipeline/create/creator_plugins.py | 23 ++++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 3076efcde7..e0e2c09f96 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -16,6 +16,7 @@ from openpype.settings import ( get_system_settings, get_project_settings ) +from openpype.lib import is_func_signature_supported from openpype.lib.attribute_definitions import ( UnknownDef, serialize_attr_defs, @@ -1774,7 +1775,7 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.plugins: if inspect.isabstract(creator_class): - self.log.info( + self.log.debug( "Skipping abstract Creator {}".format(str(creator_class)) ) continue @@ -1798,12 +1799,16 @@ class CreateContext: ).format(creator_class.host_name, self.host_name)) continue - creator = creator_class( - project_settings, - system_settings, - self, - self.headless - ) + if is_func_signature_supported( + creator_class, project_settings, self, self.headless + ): + creator = creator_class(project_settings, self, self.headless) + else: + # Backwards compatibility to pass system settings to creators + creator = creator_class( + project_settings, system_settings, self, self.headless + ) + if not creator.enabled: disabled_creators[creator_identifier] = creator continue diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 38d6b6f465..a1a6a73236 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -10,7 +10,7 @@ from abc import ( import six from openpype.settings import get_system_settings, get_project_settings -from openpype.lib import Logger +from openpype.lib import Logger, is_func_signature_supported from openpype.pipeline.plugin_discover import ( discover, register_plugin, @@ -161,7 +161,6 @@ class BaseCreator: Args: project_settings (Dict[str, Any]): Project settings. - system_settings (Dict[str, Any]): System settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ @@ -197,9 +196,7 @@ class BaseCreator: # QUESTION make this required? host_name = None - def __init__( - self, project_settings, system_settings, create_context, headless=False - ): + def __init__(self, project_settings, create_context, headless=False): # Reference to CreateContext self.create_context = create_context self.project_settings = project_settings @@ -208,10 +205,20 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless - self.apply_settings(project_settings, system_settings) + if is_func_signature_supported( + self.apply_settings, project_settings + ): + self.apply_settings(project_settings) + else: + # Backwards compatibility for system settings + self.apply_settings(project_settings, {}) - def apply_settings(self, project_settings, system_settings): - """Method called on initialization of plugin to apply settings.""" + def apply_settings(self, project_settings): + """Method called on initialization of plugin to apply settings. + + Args: + project_settings (dict[str, Any]): Project settings. + """ pass From e6b9a7d0381a6325100c65537dd744d0796c3b31 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:04:22 +0300 Subject: [PATCH 235/429] validate mesh name --- .../publish/validate_fbx_hierarchy_path.py | 235 ------------------ .../publish/validate_mesh_is_static.py | 129 ---------- .../validate_unreal_staticmesh_naming.py | 102 ++++++++ 3 files changed, 102 insertions(+), 364 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py deleted file mode 100644 index be73ccd223..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ /dev/null @@ -1,235 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate path attribute for all primitives. - -Validators are used to verify the work of artists, -by running some checks which automates the approval process. - -It's almost the same as -'validate_primitive_hierarchy_paths.py' -however this one includes extra comments for demonstration. - -FYI, path for fbx behaves a little differently. -In maya terms: -in Filmbox FBX: it sets the name of the object -in Alembic ROP: it sets the name of the shape - -This plugin is part of publish process guide. -""" - -import pyblish.api -from openpype.pipeline import ( - PublishValidationError, - OptionalPyblishPluginMixin -) -from openpype.pipeline.publish import ( - ValidateContentsOrder, - RepairAction, -) -from openpype.hosts.houdini.api.action import ( - SelectInvalidAction, - SelectROPAction, -) - -import hou - - -# Each validation can have a single repair action -# which calls the repair method -class AddDefaultPathAction(RepairAction): - label = "Add a default path" - icon = "mdi.pencil-plus-outline" - - -class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validate all primitives build hierarchy from attribute - when enabled. - - The name of the attribute must exist on the prims and have the - same name as Build Hierarchy from Attribute's `Path Attribute` - value on the FilmBox node. - This validation enables 'Build Hierarchy from Attribute' - by default. - """ - - families = ["fbx"] - hosts = ["houdini"] - label = "Validate Prims Hierarchy Path (FBX)" - - # Usually you will use this value as default - order = ValidateContentsOrder + 0.1 - - # Validation can have as many actions as you want - # all of these actions are defined in a seperate place - # unlike the repair action - actions = [SelectInvalidAction, AddDefaultPathAction, - SelectROPAction] - - # 'OptionalPyblishPluginMixin' adds the functionality to - # enable/disable plugins, It requires adding new settings. - optional = True - - # overrides InstancePlugin.process() - 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(nodes), - title=self.label - ) - - # This method was named get_invalid as a convention - # it's also used by SelectInvalidAction to select - # the returned nodes - @classmethod - def get_invalid(cls, instance): - - output_node = instance.data.get("output_node") - rop_node = hou.node(instance.data["instance_node"]) - - if output_node is None: - cls.log.error( - "SOP Output node in '%s' does not exist. " - "Ensure a valid SOP output path is set.", - rop_node.path() - ) - - return [rop_node] - - build_from_path = rop_node.parm("buildfrompath").eval() - if not build_from_path: - cls.log.debug( - "Filmbox FBX has 'Build from Path' disabled. " - "Enbaling it as default." - ) - rop_node.parm("buildfrompath").set(1) - - path_attr = rop_node.parm("pathattrib").eval() - if not path_attr: - cls.log.debug( - "Filmbox FBX node has no Path Attribute" - "value set, setting it to 'path' as default." - ) - rop_node.parm("pathattrib").set("path") - - cls.log.debug("Checking for attribute: %s", path_attr) - - # Use current frame if "frameStart" doesn't exist - # This only happens when ""trange" is 0 - frame = instance.data.get("frameStart", hou.intFrame()) - - # Get Geo at that frame - geo = output_node.geometryAtFrame(frame) - if not geo: - cls.log.warning("No geometry found," - " skipping check..") - return - - # If there are no primitives on the current frame then - # we can't check whether the path names are correct. - # So we'll just issue a warning that the check can't - # be done consistently and skip validation. - - if len(geo.iterPrims()) == 0: - cls.log.warning( - "No primitives found on current frame." - " Validation for primitive hierarchy" - " paths will be skipped," - " thus can't be validated." - ) - return - - # Check if there are any values for the primitives - attrib = geo.findPrimAttrib(path_attr) - if not attrib: - cls.log.info( - "Geometry Primitives are missing " - "path attribute: `%s`", path_attr - ) - return [output_node] - - # Ensure at least a single string value is present - if not attrib.strings(): - cls.log.info( - "Primitive path attribute has no " - "string values: %s", path_attr - ) - return [output_node] - - paths = geo.primStringAttribValues(path_attr) - # Ensure all primitives are set to a valid path - # Collect all invalid primitive numbers - invalid_prims = [i for i, path in enumerate(paths) if not path] - if invalid_prims: - num_prims = len(geo.iterPrims()) # faster than len(geo.prims()) - cls.log.info( - "Prims have no value for attribute `%s` " - "(%s of %s prims)", - path_attr, len(invalid_prims), num_prims - ) - return [output_node] - - # what repair action expects to find and call - @classmethod - def repair(cls, instance): - """Add a default path attribute Action. - - It is a helper action more than a repair action, - used to add a default single value for the path. - """ - - rop_node = hou.node(instance.data["instance_node"]) - # I'm doing so because an artist may change output node - # before clicking the button. - output_node = rop_node.parm("startnode").evalAsNode() - - if not output_node: - cls.log.debug( - "Action isn't performed, invalid SOP Path on %s", - rop_node - ) - return - - # This check to prevent the action from running multiple times. - # git_invalid only returns [output_node] when - # path attribute is the problem - if cls.get_invalid(instance) != [output_node]: - return - - path_attr = rop_node.parm("pathattrib").eval() - - path_node = output_node.parent().createNode("name", - "AUTO_PATH") - path_node.parm("attribname").set(path_attr) - path_node.parm("name1").set('`opname("..")`_GEO') - - cls.log.debug( - "'%s' was created. It adds '%s' with a default" - " single value", path_node, path_attr - ) - - path_node.setGenericFlag(hou.nodeFlag.DisplayComment, True) - path_node.setComment( - 'Auto path node was created automatically by ' - '"Add a default path attribute"' - '\nFeel free to modify or replace it.' - ) - - if output_node.type().name() in ["null", "output"]: - # Connect before - path_node.setFirstInput(output_node.input(0)) - path_node.moveToGoodPosition() - output_node.setFirstInput(path_node) - output_node.moveToGoodPosition() - else: - # Connect after - path_node.setFirstInput(output_node) - rop_node.parm("startnode").set(path_node.path()) - path_node.moveToGoodPosition() - - cls.log.debug( - "SOP path on '%s' updated to new output node '%s'", - rop_node, path_node - ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py deleted file mode 100644 index ac80fda537..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ /dev/null @@ -1,129 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate mesh is static. - -This plugin is part of publish process guide. -""" - -import pyblish.api -from openpype.pipeline import PublishValidationError -from openpype.pipeline.publish import ( - ValidateContentsOrder, - RepairAction, -) -from openpype.hosts.houdini.api.action import ( - SelectInvalidAction, - SelectROPAction, -) - -import hou - - -# Each validation can have a single repair action -# which calls the repair method -class FreezeTimeAction(RepairAction): - label = "Freeze Time" - icon = "ei.pause-alt" - - -class ValidateMeshIsStatic(pyblish.api.InstancePlugin): - """Validate mesh is static. - - It checks if node is time dependant. - """ - - families = ["staticMesh"] - hosts = ["houdini"] - label = "Validate mesh is static" - - # Usually you will use this value as default - order = ValidateContentsOrder + 0.1 - - # Validation can have as many actions as you want - # all of these actions are defined in a seperate place - # unlike the repair action - actions = [FreezeTimeAction, SelectInvalidAction, - SelectROPAction] - - # overrides InstancePlugin.process() - 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(nodes), - title=self.label - ) - - # This method was named get_invalid as a convention - # it's also used by SelectInvalidAction to select - # the returned nodes - @classmethod - def get_invalid(cls, instance): - - output_node = instance.data.get("output_node") - if output_node.isTimeDependent(): - cls.log.info("Mesh is not static!") - return [output_node] - - # what repair action expects to find and call - @classmethod - def repair(cls, instance): - """Adds a time shift node. - - It should kill time dependency. - """ - - rop_node = hou.node(instance.data["instance_node"]) - # I'm doing so because an artist may change output node - # before clicking the button. - output_node = rop_node.parm("startnode").evalAsNode() - - if not output_node: - cls.log.debug( - "Action isn't performed, invalid SOP Path on %s", - rop_node - ) - return - - # This check to prevent the action from running multiple times. - # git_invalid only returns [output_node] when - # path attribute is the problem - if cls.get_invalid(instance) != [output_node]: - return - - time_shift = output_node.parent().createNode("timeshift", - "freeze_time") - time_shift.parm("frame").deleteAllKeyframes() - - frame = instance.data.get("frameStart", hou.intFrame()) - time_shift.parm("frame").set(frame) - - cls.log.debug( - "'%s' was created. It will kill time dependency.", - time_shift - ) - - time_shift.setGenericFlag(hou.nodeFlag.DisplayComment, True) - time_shift.setComment( - 'This node was created automatically by ' - '"Freeze Time" Action' - '\nFeel free to modify or replace it.' - ) - - if output_node.type().name() in ["null", "output"]: - # Connect before - time_shift.setFirstInput(output_node.input(0)) - time_shift.moveToGoodPosition() - output_node.setFirstInput(time_shift) - output_node.moveToGoodPosition() - else: - # Connect after - time_shift.setFirstInput(output_node) - rop_node.parm("startnode").set(time_shift.path()) - time_shift.moveToGoodPosition() - - cls.log.debug( - "SOP path on '%s' updated to new output node '%s'", - rop_node, time_shift - ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py new file mode 100644 index 0000000000..be450a0410 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ValidateContentsOrder + +from openpype.hosts.houdini.api.action import SelectInvalidAction + +import hou + + +class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate name of Unreal Static Mesh + + This validator checks if output node name has a collision prefix: + - UBX + - UCP + - USP + - UCX + + This validator also checks if subset name is correct + - {static mesh prefix}_{Asset-Name}{Variant}. + + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Unreal Static Mesh Name (FBX)" + order = ValidateContentsOrder + 0.1 + actions = [SelectInvalidAction] + + optional = True + + @classmethod + def apply_settings(cls, project_settings, system_settings): + settings = ( + project_settings["houdini"]["create"]["CreateUnrealStaticMesh"] + ) + cls.collision_prefixes = settings["collision_prefixes"] + cls.static_mesh_prefix = settings["static_mesh_prefix"] + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes), + title=self.label + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + rop_node = hou.node(instance.data["instance_node"]) + output_node = instance.data.get("output_node") + cls.log.debug(cls.collision_prefixes) + + # Check nodes names + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + for prefix in cls.collision_prefixes: + if child.name().startswith(prefix): + invalid.append(child) + cls.log.error( + "Invalid name: Child node '%s' in '%s' " + "has a collision prefix '%s'" + , child.name(), output_node.path(), prefix + ) + break + else: + cls.log.debug(output_node.name()) + for prefix in cls.collision_prefixes: + if output_node.name().startswith(prefix): + invalid.append(output_node) + cls.log.error( + "Invalid name: output node '%s' " + "has a collision prefix '%s'" + , output_node.name(), prefix + ) + + # Check subset name + subset_name = "{}_{}{}".format( + cls.static_mesh_prefix, + instance.data["asset"], + instance.data.get("variant", "") + ) + + if instance.data.get("subset") != subset_name: + invalid.append(rop_node) + cls.log.error( + "Invalid subset name on rop node '%s' should be '%s'." + , rop_node.path(), subset_name + ) + + return invalid From 128939068084b3b5af50db7812d1a9b8021a471c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:23:53 +0300 Subject: [PATCH 236/429] update settings --- .../plugins/publish/validate_unreal_staticmesh_naming.py | 6 +++++- openpype/settings/defaults/project_settings/houdini.json | 2 +- .../projects_schema/schemas/schema_houdini_publish.json | 4 ++-- server_addon/houdini/server/settings/publish_plugins.py | 6 +++--- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index be450a0410..24ef304185 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -14,7 +14,7 @@ import hou class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): - """Validate name of Unreal Static Mesh + """Validate name of Unreal Static Mesh. This validator checks if output node name has a collision prefix: - UBX @@ -44,6 +44,10 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, cls.static_mesh_prefix = settings["static_mesh_prefix"] def process(self, instance): + + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index c39eb717fd..65f13fa1ab 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -111,7 +111,7 @@ "optional": true, "active": true }, - "ValidateFBXPrimitiveHierarchyPaths": { + "ValidateUnrealStaticMeshName": { "enabled": true, "optional": true, "active": true diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index d58b36eff1..4339f86db6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -45,8 +45,8 @@ "label": "ValidateContainers" }, { - "key": "ValidateFBXPrimitiveHierarchyPaths", - "label": "Validate Path Attribute for FBX" + "key": "ValidateUnrealStaticMeshName", + "label": "Validate Unreal Static Mesh Name" } ] } diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 1e5cd7f551..335751e5f9 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -164,9 +164,9 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Latest Containers.") - ValidateFBXPrimitiveHierarchyPaths: ValidateContainersModel = Field( + ValidateUnrealStaticMeshName: ValidateContainersModel = Field( default_factory=ValidateContainersModel, - title="Validate Path Attribute for FBX.") + title="Validate Unreal Static Mesh Name.") DEFAULT_HOUDINI_PUBLISH_SETTINGS = { @@ -187,7 +187,7 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "optional": True, "active": True }, - "ValidateFBXPrimitiveHierarchyPaths": { + "ValidateUnrealStaticMeshName": { "enabled": True, "optional": True, "active": True From c73d76ef15289f868d016c689ecaa880c3144262 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:33:54 +0300 Subject: [PATCH 237/429] resolve houn --- .../plugins/create/create_unreal_staticmesh.py | 13 +++++++------ .../publish/validate_unreal_staticmesh_naming.py | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 0b5b313d9e..6002f7b1d7 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -74,12 +74,13 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): attrs = super().get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", - tooltip="Create an extra root for the Export node " - "when it’s a subnetwork. This causes the " - "exporting subnetwork node to be " - "represented in the FBX file.", - default=False, - label="Create Root for Subnet") + tooltip="Create an extra root for the " + "Export node when it’s a " + "subnetwork. This causes the " + "exporting subnetwork node to be " + "represented in the FBX file.", + default=False, + label="Create Root for Subnet") vcformat = EnumDef("vcformat", items={ 0: "Maya Compatible (MC)", diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 24ef304185..a3426d2f19 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -74,8 +74,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid.append(child) cls.log.error( "Invalid name: Child node '%s' in '%s' " - "has a collision prefix '%s'" - , child.name(), output_node.path(), prefix + "has a collision prefix '%s'", + child.name(), output_node.path(), prefix ) break else: @@ -85,8 +85,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid.append(output_node) cls.log.error( "Invalid name: output node '%s' " - "has a collision prefix '%s'" - , output_node.name(), prefix + "has a collision prefix '%s'", + output_node.name(), prefix ) # Check subset name @@ -99,8 +99,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, if instance.data.get("subset") != subset_name: invalid.append(rop_node) cls.log.error( - "Invalid subset name on rop node '%s' should be '%s'." - , rop_node.path(), subset_name + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name ) return invalid From 1f452510d8e4fb38deaebe59eb5f4d970d2b5aa1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:35:57 +0300 Subject: [PATCH 238/429] resolve hound --- .../plugins/publish/validate_unreal_staticmesh_naming.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index a3426d2f19..7820be4009 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -99,8 +99,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, if instance.data.get("subset") != subset_name: invalid.append(rop_node) cls.log.error( - "Invalid subset name on rop node '%s' should be '%s'.", - rop_node.path(), subset_name + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name ) return invalid From 1f6df907eb4934d471dcbad96b10d1c08566d5ca Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 16:54:05 +0200 Subject: [PATCH 239/429] fixed typehints --- openpype/lib/python_module_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index a10263f991..bedf19562d 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -270,8 +270,8 @@ def is_func_signature_supported(func, *args, **kwargs): Args: func (function): A function where the signature should be tested. - *args (tuple[Any]): Positional arguments for function signature. - **kwargs (dict[str, Any]): Keyword arguments for function signature. + *args (Any): Positional arguments for function signature. + **kwargs (Any): Keyword arguments for function signature. Returns: bool: Function can pass in arguments. From fee3c950d3a728ef9d22bfc857eb14a15620ce5b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:00:14 +0200 Subject: [PATCH 240/429] removed deprecated 'abstractproperty' --- openpype/pipeline/create/creator_plugins.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index a1a6a73236..b38c1199e6 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,11 +1,7 @@ import copy import collections -from abc import ( - ABCMeta, - abstractmethod, - abstractproperty -) +from abc import ABCMeta, abstractmethod import six @@ -84,7 +80,8 @@ class SubsetConvertorPlugin(object): def host(self): return self._create_context.host - @abstractproperty + @property + @abstractmethod def identifier(self): """Converted identifier. @@ -231,7 +228,8 @@ class BaseCreator: return self.family - @abstractproperty + @property + @abstractmethod def family(self): """Family that plugin represents.""" From e2c60831c2a32e260615f9842e7ecf50109b48a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:07:28 +0200 Subject: [PATCH 241/429] apply settings in TVPaint does not expect system settings --- openpype/hosts/tvpaint/plugins/create/create_render.py | 2 +- openpype/hosts/tvpaint/plugins/create/create_review.py | 2 +- openpype/hosts/tvpaint/plugins/create/create_workfile.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index 2369c7329f..656ea5d80b 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -139,7 +139,7 @@ class CreateRenderlayer(TVPaintCreator): # - Mark by default instance for review mark_for_review = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_layer"] ) diff --git a/openpype/hosts/tvpaint/plugins/create/create_review.py b/openpype/hosts/tvpaint/plugins/create/create_review.py index 886dae7c39..7bb7510a8e 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_review.py +++ b/openpype/hosts/tvpaint/plugins/create/create_review.py @@ -12,7 +12,7 @@ class TVPaintReviewCreator(TVPaintAutoCreator): # Settings active_on_create = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_review"] ) diff --git a/openpype/hosts/tvpaint/plugins/create/create_workfile.py b/openpype/hosts/tvpaint/plugins/create/create_workfile.py index 41347576d5..c3982c0eca 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_workfile.py +++ b/openpype/hosts/tvpaint/plugins/create/create_workfile.py @@ -9,7 +9,7 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): label = "Workfile" icon = "fa.file-o" - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_workfile"] ) From 21d5d4cad2705edc8aadc593356a796a14a155e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:08:13 +0200 Subject: [PATCH 242/429] removed system settings usage from traypublisher --- .../hosts/traypublisher/plugins/create/create_movie_batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py index 1bed07f785..3454b6e135 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py @@ -36,7 +36,7 @@ class BatchMovieCreator(TrayPublishCreator): # Position batch creator after simple creators order = 110 - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): creator_settings = ( project_settings["traypublisher"]["create"]["BatchMovieCreator"] ) From cb3ef02fc377ad375ebf2ccf061da735414d7703 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:10:07 +0200 Subject: [PATCH 243/429] fusion creator does not expect system settings --- openpype/hosts/fusion/plugins/create/create_saver.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 04898d0a45..344deffb42 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -250,11 +250,7 @@ class CreateSaver(NewCreator): label="Review", ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings From 09d31e81ef01fd94b64d6bcd14dde61960c44a91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:10:26 +0200 Subject: [PATCH 244/429] removed duplicated attribute --- openpype/hosts/fusion/plugins/create/create_saver.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 344deffb42..39edca4de3 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -30,10 +30,6 @@ class CreateSaver(NewCreator): instance_attributes = [ "reviewable" ] - default_variants = [ - "Main", - "Mask" - ] # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( From 3ba7939222c920367a68153a46c8a4ded8684369 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:10:40 +0200 Subject: [PATCH 245/429] removed usage of system settings from after effects --- openpype/hosts/aftereffects/plugins/create/create_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index dcf424b44f..fbe600ae68 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -164,7 +164,7 @@ class RenderCreator(Creator): api.get_stub().rename_item(comp_id, new_comp_name) - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["aftereffects"]["create"]["RenderCreator"] ) From 539120f71861569dfce8e1e2bc39a87fe408f9aa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:11:21 +0200 Subject: [PATCH 246/429] removed usage of system settings from houdini --- openpype/hosts/houdini/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 70c837205e..730a627dc3 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -296,7 +296,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """ return [hou.ropNodeTypeCategory()] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" settings_name = self.settings_name From cb3e83118b29e48b430da8a5eaf124e928bad3bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:14:39 +0200 Subject: [PATCH 247/429] maya create plugins do not expecte system settings --- openpype/hosts/maya/api/plugin.py | 2 +- openpype/hosts/maya/plugins/create/create_animation.py | 6 ++---- openpype/hosts/maya/plugins/create/create_render.py | 2 +- .../hosts/maya/plugins/create/create_unreal_skeletalmesh.py | 2 +- .../hosts/maya/plugins/create/create_unreal_staticmesh.py | 2 +- openpype/hosts/maya/plugins/create/create_vrayscene.py | 2 +- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 00d6602ef9..3f383fafb8 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -260,7 +260,7 @@ class MayaCreator(NewCreator, MayaCreatorBase): default=True) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" settings_name = self.settings_name diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index 214ac18aef..115c73c0d3 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -81,10 +81,8 @@ class CreateAnimation(plugin.MayaHiddenCreator): return defs - def apply_settings(self, project_settings, system_settings): - super(CreateAnimation, self).apply_settings( - project_settings, system_settings - ) + def apply_settings(self, project_settings): + super(CreateAnimation, self).apply_settings(project_settings) # Hardcoding creator to be enabled due to existing settings would # disable the creator causing the creator plugin to not be # discoverable. diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index cc5c1eb205..6266689af4 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -34,7 +34,7 @@ class CreateRenderlayer(plugin.RenderlayerCreator): render_settings = {} @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py index 4e2a99eced..3c9a79156a 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py @@ -21,7 +21,7 @@ class CreateUnrealSkeletalMesh(plugin.MayaCreator): # Defined in settings joint_hints = set() - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["create"]["CreateUnrealSkeletalMesh"] diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index 3f96d91a54..025b39fa55 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -16,7 +16,7 @@ class CreateUnrealStaticMesh(plugin.MayaCreator): # Defined in settings collision_prefixes = [] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Apply project settings to creator""" settings = project_settings["maya"]["create"]["CreateUnrealStaticMesh"] self.collision_prefixes = settings["collision_prefixes"] diff --git a/openpype/hosts/maya/plugins/create/create_vrayscene.py b/openpype/hosts/maya/plugins/create/create_vrayscene.py index d601dceb54..2726979d30 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayscene.py +++ b/openpype/hosts/maya/plugins/create/create_vrayscene.py @@ -22,7 +22,7 @@ class CreateVRayScene(plugin.RenderlayerCreator): singleton_node_name = "vraysceneMain" @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): From 9338c163057d6c644bb6e113b51ed05170f8a951 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:18:10 +0200 Subject: [PATCH 248/429] photoshop create plugins do not expect system settings --- openpype/hosts/photoshop/plugins/create/create_flatten_image.py | 2 +- openpype/hosts/photoshop/plugins/create/create_image.py | 2 +- openpype/hosts/photoshop/plugins/create/create_review.py | 2 +- openpype/hosts/photoshop/plugins/create/create_workfile.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index 3bc61c8184..9d4189a1a3 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -98,7 +98,7 @@ class AutoImageCreator(PSAutoCreator): ) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["AutoImageCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index f3165fca57..8d3ac9f459 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -171,7 +171,7 @@ class ImageCreator(Creator): ) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ImageCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_review.py b/openpype/hosts/photoshop/plugins/create/create_review.py index 064485d465..63751d94e4 100644 --- a/openpype/hosts/photoshop/plugins/create/create_review.py +++ b/openpype/hosts/photoshop/plugins/create/create_review.py @@ -18,7 +18,7 @@ class ReviewCreator(PSAutoCreator): it will get recreated in next publish either way). """ - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ReviewCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_workfile.py b/openpype/hosts/photoshop/plugins/create/create_workfile.py index d498f0549c..1b255de3a3 100644 --- a/openpype/hosts/photoshop/plugins/create/create_workfile.py +++ b/openpype/hosts/photoshop/plugins/create/create_workfile.py @@ -19,7 +19,7 @@ class WorkfileCreator(PSAutoCreator): in next publish automatically). """ - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["WorkfileCreator"] ) From 7441d2617ab118db431b0ce57079ea25ffd8209b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:19:30 +0200 Subject: [PATCH 249/429] add missing tvpaint creators --- openpype/hosts/tvpaint/plugins/create/create_render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index 656ea5d80b..b7a7c208d9 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -387,7 +387,7 @@ class CreateRenderPass(TVPaintCreator): # Settings mark_for_review = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_pass"] ) @@ -690,7 +690,7 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): group_idx_offset = 10 group_idx_padding = 3 - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings ["tvpaint"] @@ -1029,7 +1029,7 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): mark_for_review = True active_on_create = False - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_scene"] ) From ce3e9a52401fdd85a6574b6fafc0d23351e69135 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:19:39 +0200 Subject: [PATCH 250/429] nuke create plugins do not expect system settings --- openpype/hosts/nuke/api/plugin.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 6d48c09d60..a0e1525cd0 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -379,11 +379,7 @@ class NukeWriteCreator(NukeCreator): sys.exc_info()[2] ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings From 34d7a4f477c109ec419a2048d72cdc657bae7e51 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 1 Sep 2023 17:24:01 +0200 Subject: [PATCH 251/429] removed unnecessary line Co-authored-by: Roy Nieterau --- openpype/lib/attribute_definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index a71709cace..095b096255 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -482,7 +482,6 @@ class EnumDef(AbstractAttrDef): return copy.deepcopy(self.default) return list(self._item_values.intersection(value)) - def serialize(self): data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) From 5de86cb6df5c852b590a83f72d4b3bfe1cdf1ebb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:33:57 +0200 Subject: [PATCH 252/429] do not override scale factor rounding policy if has defined value through env variable --- openpype/tools/utils/lib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 2df46c1eae..723e71e7aa 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -170,8 +170,12 @@ def get_openpype_qt_app(): if attr is not None: QtWidgets.QApplication.setAttribute(attr) - if hasattr( - QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy" + policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY") + if ( + hasattr( + QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy" + ) + and not policy ): QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough From 88ab56db12f21a6aabf558bf1c9b1ab8471a4011 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 18:16:54 +0200 Subject: [PATCH 253/429] backwards compatibility is kept with waring message --- openpype/pipeline/create/context.py | 16 ++++++---------- openpype/pipeline/create/creator_plugins.py | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index e0e2c09f96..f9e3f86652 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -16,7 +16,6 @@ from openpype.settings import ( get_system_settings, get_project_settings ) -from openpype.lib import is_func_signature_supported from openpype.lib.attribute_definitions import ( UnknownDef, serialize_attr_defs, @@ -1799,15 +1798,12 @@ class CreateContext: ).format(creator_class.host_name, self.host_name)) continue - if is_func_signature_supported( - creator_class, project_settings, self, self.headless - ): - creator = creator_class(project_settings, self, self.headless) - else: - # Backwards compatibility to pass system settings to creators - creator = creator_class( - project_settings, system_settings, self, self.headless - ) + creator = creator_class( + project_settings, + system_settings, + self, + self.headless + ) if not creator.enabled: disabled_creators[creator_identifier] = creator diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index b38c1199e6..bd9d1d16c3 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -193,7 +193,9 @@ class BaseCreator: # QUESTION make this required? host_name = None - def __init__(self, project_settings, create_context, headless=False): + def __init__( + self, project_settings, system_settings, create_context, headless=False + ): # Reference to CreateContext self.create_context = create_context self.project_settings = project_settings @@ -202,13 +204,26 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless + expect_system_settings = False if is_func_signature_supported( self.apply_settings, project_settings ): self.apply_settings(project_settings) else: + expect_system_settings = True # Backwards compatibility for system settings - self.apply_settings(project_settings, {}) + self.apply_settings(project_settings, system_settings) + + init_overriden = self.__class__.__init__ is not BaseCreator.__init__ + if init_overriden or expect_system_settings: + self.log.warning(( + "WARNING: Source - Create plugin {}." + " System settings argument will not be passed to" + " '__init__' and 'apply_settings' methods in future versions" + " of OpenPype. Planned version to drop the support" + " is 3.15.6 or 3.16.0. Please contact Ynput core team if you" + " need to keep system settings." + ).format(self.__class__.__name__)) def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings. From 1b8d8e68f2b7e41f3f141a3ad8570b49ae0d7be5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Sep 2023 18:23:45 +0200 Subject: [PATCH 254/429] Harmony: refresh code for current Deadline (#5493) * Explicitly set Python3 for Harmony OP plugin * Fix method call Without it it would always return True * Explicitly set render instance to farm * Added Harmony 22 executable This plugin might not be necessary anymore for current Harmonies (at least for 22 works original Harmony plugin in DL) * Removed logging * fix - remove explicit disabling of review instance.data["review"] could be False only if review shouldn't be explicitly done. This is not possible in old publisher. TODO must be implemented in NP. --- .../plugins/publish/collect_farm_render.py | 2 +- .../publish/submit_harmony_deadline.py | 2 +- .../HarmonyOpenPype/HarmonyOpenPype.param | 20 ++++++++++++++++++- .../HarmonyOpenPype/HarmonyOpenPype.py | 1 + 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 5daa93cddb..af825c052a 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -147,13 +147,13 @@ class CollectFarmRender(publish.AbstractCollectRender): attachTo=False, setMembers=[node], publish=info[4], - review=False, renderer=None, priority=50, name=node.split("/")[1], family="render.farm", families=["render.farm"], + farm=True, resolutionWidth=context.data["resolutionWidth"], resolutionHeight=context.data["resolutionHeight"], diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 2c37268f04..16e703fc91 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -265,7 +265,7 @@ class HarmonySubmitDeadline( job_info.SecondaryPool = self._instance.data.get("secondaryPool") job_info.ChunkSize = self.chunk_size batch_name = os.path.basename(self._instance.data["source"]) - if is_in_tests: + if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") job_info.BatchName = batch_name job_info.Department = self.department diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param index ff2949766c..43a54a464e 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param @@ -77,4 +77,22 @@ CategoryOrder=0 Index=4 Label=Harmony 20 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium \ No newline at end of file +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium + +[Harmony_RenderExecutable_21] +Type=multilinemultifilename +Category=Render Executables +CategoryOrder=0 +Index=4 +Label=Harmony 21 Render Executable +Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 21 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 21 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_21/lnx86_64/bin/HarmonyPremium + +[Harmony_RenderExecutable_22] +Type=multilinemultifilename +Category=Render Executables +CategoryOrder=0 +Index=4 +Label=Harmony 22 Render Executable +Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 22 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 22 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_22/lnx86_64/bin/HarmonyPremium diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py index 2f6e9cf379..32ed76b58d 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 from System import * from System.Diagnostics import * from System.IO import * From 869c6277ff1d89599086169926dfb48673c088c0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 21:03:09 +0300 Subject: [PATCH 255/429] BigRoy's comment --- .../publish/validate_review_colorspace.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 3f5e5bc354..e5d4756556 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -37,15 +37,15 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return - invalid = self.get_invalid(instance) - if invalid: + invalid_nodes, message = self.get_invalid_with_message(instance) + if invalid_nodes: raise PublishValidationError( - ("'OCIO Colorspace' parameter is not valid."), + message, title=self.label ) @classmethod - def get_invalid(cls, instance): + def get_invalid_with_message(cls, instance): rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: @@ -53,26 +53,31 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, "Default Houdini colorspace is used, " " skipping check.." ) - return + return None, None if rop_node.evalParm("colorcorrect") != 2: # any colorspace settings other than default requires # 'Color Correct' parm to be set to 'OpenColorIO' - rop_node.setParms({"colorcorrect": 2}) - cls.log.debug( - "'Color Correct' parm on '{}' has been set to" - " 'OpenColorIO'".format(rop_node) + error = ( + "'Color Correction' parm on '{}' ROP must be set to" + " 'OpenColorIO'".format(rop_node.path()) ) + return rop_node , error if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): - cls.log.error( - "'OCIO Colorspace' value on '{}' is not valid, " - "select a valid option from the dropdown menu." - .format(rop_node) + error = ( + "Invalid value: Colorspace name doesn't exist.\n" + "Check 'OCIO Colorspace' parameter on '{}' ROP" + .format(rop_node.path()) ) - return rop_node + return rop_node, error + + @classmethod + def get_invalid(cls, instance): + nodes, _ = cls.get_invalid_with_message(instance) + return nodes @classmethod def repair(cls, instance): @@ -84,6 +89,13 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, rop_node = hou.node(instance.data["instance_node"]) + if rop_node.evalParm("colorcorrect") != 2: + rop_node.setParms({"colorcorrect": 2}) + cls.log.debug( + "'Color Correction' parm on '{}' has been set to" + " 'OpenColorIO'".format(rop_node.path()) + ) + # Get default view colorspace name default_view_space = get_default_display_view_colorspace() From 087aca6d8b9377c054e623bc2f12f6b418a69aa0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 21:04:59 +0300 Subject: [PATCH 256/429] resolve hound --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index e5d4756556..47370678d0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -62,7 +62,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, "'Color Correction' parm on '{}' ROP must be set to" " 'OpenColorIO'".format(rop_node.path()) ) - return rop_node , error + return rop_node, error if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): From 0cf8f8f42dc0f3b348c55ee02d7e8cdc784838e2 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 2 Sep 2023 03:24:31 +0000 Subject: [PATCH 257/429] [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 12f797228b..7de6fd752b 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.3" +__version__ = "3.16.5-nightly.4" From dc684b65f38ddeb6900343f35a8d005049307798 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Sep 2023 03:25:07 +0000 Subject: [PATCH 258/429] 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 669bf391cd..83a4b6a8d4 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.5-nightly.4 - 3.16.5-nightly.3 - 3.16.5-nightly.2 - 3.16.5-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.1 - 3.14.8 - 3.14.8-nightly.4 - - 3.14.8-nightly.3 validations: required: true - type: dropdown From c2b948d35fea1f0cf2f7146bb0085fc3bd2dfeef Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 11:09:47 +0300 Subject: [PATCH 259/429] BigRoy's comments --- .../publish/validate_review_colorspace.py | 27 ++++--------------- openpype/pipeline/colorspace.py | 7 +---- openpype/scripts/ocio_wrapper.py | 11 ++++---- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 47370678d0..d457a295c5 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -37,47 +37,30 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return - invalid_nodes, message = self.get_invalid_with_message(instance) - if invalid_nodes: - raise PublishValidationError( - message, - title=self.label - ) - - @classmethod - def get_invalid_with_message(cls, instance): - - rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: - cls.log.debug( + self.log.debug( "Default Houdini colorspace is used, " " skipping check.." ) - return None, None + return + rop_node = hou.node(instance.data["instance_node"]) if rop_node.evalParm("colorcorrect") != 2: # any colorspace settings other than default requires # 'Color Correct' parm to be set to 'OpenColorIO' - error = ( + raise PublishValidationError( "'Color Correction' parm on '{}' ROP must be set to" " 'OpenColorIO'".format(rop_node.path()) ) - return rop_node, error if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): - error = ( + raise PublishValidationError( "Invalid value: Colorspace name doesn't exist.\n" "Check 'OCIO Colorspace' parameter on '{}' ROP" .format(rop_node.path()) ) - return rop_node, error - - @classmethod - def get_invalid(cls, instance): - nodes, _ = cls.get_invalid_with_message(instance) - return nodes @classmethod def repair(cls, instance): diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3dd33d0425..e167e18cfb 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -648,15 +648,10 @@ def get_display_view_colorspace_subprocess(config_path, display, view): "--out_path", tmp_json_path, "--display", display, "--view", view - ] log.debug("Executing: {}".format(" ".join(args))) - process_kwargs = { - "logger": log - } - - run_openpype_process(*args, **process_kwargs) + run_openpype_process(*args, logger=log) # return default view colorspace name with open(tmp_json_path, "r") as f: diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index cae6e6975b..2c11bb7eeb 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -225,7 +225,7 @@ def get_display_view_colorspace_name(in_path, out_path, display, view): """Aggregate view colorspace name to file. - Wrapper command for processes without acces to OpenColorIO + Wrapper command for processes without access to OpenColorIO Args: in_path (str): config file path string @@ -239,15 +239,14 @@ def get_display_view_colorspace_name(in_path, out_path, --out_path= --display= --view= """ - json_path = Path(out_path) - out_data = _get_display_view_colorspace_name(in_path, - display, view) + display, + view) - with open(json_path, "w") as f: + with open(out_path, "w") as f: json.dump(out_data, f) - print(f"Display view colorspace saved to '{json_path}'") + print(f"Display view colorspace saved to '{out_path}'") if __name__ == '__main__': main() From 938dc72d9179ee8938a98f0ce326c4dfadda5657 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 11:56:10 +0300 Subject: [PATCH 260/429] revise creator and collector --- .../create/create_unreal_staticmesh.py | 36 +++---------------- .../plugins/publish/collect_fbx_type.py | 33 ----------------- .../publish/collect_staticmesh_type.py | 20 +++++++++++ .../validate_primitive_hierarchy_paths.py | 2 +- 4 files changed, 26 insertions(+), 65 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/collect_fbx_type.py create mode 100644 openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 6002f7b1d7..ca5e2e8fb4 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -1,21 +1,5 @@ # -*- coding: utf-8 -*- -"""Creator plugin for creating Unreal Static Meshes. - -Unreal Static Meshes will be published as FBX. - -Filmbox by default expects an ObjNode -however, we set the sop node explictly -to eleminate any confusion. - -This will make Filmbox to ignore any object transformations! - -get_obj_output selects -the output sop with mimimum idx -or the node with render flag isntead. - -This plugin is part of publish process guide. -""" - +"""Creator for Unreal Static Meshes.""" from openpype.hosts.houdini.api import plugin from openpype.lib import BoolDef, EnumDef @@ -25,30 +9,23 @@ import hou class CreateUnrealStaticMesh(plugin.HoudiniCreator): """Unreal Static Meshes with collisions. """ - # you should set identifier = "io.openpype.creators.houdini.unrealstaticmesh.fbx" label = "Unreal - Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" - # optional to set - default_variant = "Main" - # 'default_variants' will be overriden by settings. - default_variants = ["Main", "Test"] + default_variants = ["Main"] - # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): - # set node type instance_data.update({"node_type": "filmboxfbx"}) - # create instance (calls HoudiniCreator.create()) instance = super(CreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) - # get the created node + # get the created rop node instance_node = hou.node(instance.get("instance_node")) # get parms @@ -61,21 +38,19 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) - # Overrides HoudiniCreator.get_network_categories() def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] - # Overrides HoudiniCreator.get_pre_create_attr_defs() def get_pre_create_attr_defs(self): """Add settings for users. """ attrs = super().get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " - "Export node when it’s a " + "Export node when it's a " "subnetwork. This causes the " "exporting subnetwork node to be " "represented in the FBX file.", @@ -98,7 +73,6 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): return attrs + [createsubnetroot, vcformat, convert_units] - # Overrides BaseCreator.get_dynamic_data() def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): @@ -113,7 +87,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): return dynamic_data def get_parms(self, subset_name, pre_create_data): - """Get parameters values for this specific node.""" + """Get parameters values. """ # 1. get output path output_path = hou.text.expandString( diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py deleted file mode 100644 index 6ac40a4f50..0000000000 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Collector for filmboxfbx types. - -Collectors act as a pre process for the validation stage. -It is used mainly to update instance.data - -P.S. - There are some collectors that run by default - for all types. - -This plugin is part of publish process guide. -""" -import pyblish.api - - -class CollectFilmboxfbxType(pyblish.api.InstancePlugin): - """Collect data type for fbx instance.""" - - hosts = ["houdini"] - families = ["staticMesh"] - label = "Collect type of fbx" - - # Usually you will use this value as default - order = pyblish.api.CollectorOrder - - # overrides InstancePlugin.process() - def process(self, instance): - - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 - # such a condition can be used to differentiate between - # instances by identifier because sometimes instances - # may have the same family but different identifier - # e.g. bgeo and alembic - instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py new file mode 100644 index 0000000000..8fb07c1c5c --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""Collector for staticMesh types. """ + +import pyblish.api + + +class CollectStaticMeshType(pyblish.api.InstancePlugin): + """Collect data type for fbx instance.""" + + hosts = ["houdini"] + families = ["staticMesh"] + label = "Collect type of staticMesh" + + order = pyblish.api.CollectorOrder + + def process(self, instance): + + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 + # Marking this instance as FBX which triggers the FBX extractor. + instance.data["families"] += ["fbx"] 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 930978ef16..471fa5b6d1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -26,7 +26,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): order = ValidateContentsOrder + 0.1 families = ["abc"] hosts = ["houdini"] - label = "Validate Prims Hierarchy Path (ABC)" + label = "Validate Prims Hierarchy Path" actions = [AddDefaultPathAction] def process(self, instance): From 115dd54733c83744548643e720b108d00ef1e529 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 10:56:30 +0200 Subject: [PATCH 261/429] fix repaint when custom text changed --- openpype/tools/utils/multiselection_combobox.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/tools/utils/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py index 13b396a059..34361fca17 100644 --- a/openpype/tools/utils/multiselection_combobox.py +++ b/openpype/tools/utils/multiselection_combobox.py @@ -75,11 +75,11 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def set_placeholder_text(self, text): self._placeholder_text = text + self._update_size_hint() def set_custom_text(self, text): self._custom_text = text - self.update() - self.updateGeometry() + self._update_size_hint() def focusInEvent(self, event): self.focused_in.emit() @@ -266,7 +266,6 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def _update_size_hint(self): if self._custom_text is not None: self.update() - self.repaint() return self._lines = {} @@ -313,7 +312,6 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.update() self.updateGeometry() - self.repaint() def sizeHint(self): value = super(MultiSelectionComboBox, self).sizeHint() From 6b1d060a8133643cebbf92e8c3e1fe93c84fbed9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 11:00:00 +0200 Subject: [PATCH 262/429] updated docstring --- openpype/lib/attribute_definitions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 095b096255..a71d6cc72a 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -424,7 +424,10 @@ class TextDef(AbstractAttrDef): class EnumDef(AbstractAttrDef): - """Enumeration of single item from items. + """Enumeration of items. + + Enumeration of single item from items. Or list of items if multiselection + is enabled. Args: items (Union[list[str], list[dict[str, Any]]): Items definition that From 0f39ccf0168d8e24ffed0d12287db8a15d99a38f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 4 Sep 2023 11:05:27 +0200 Subject: [PATCH 263/429] Fix - files on representation cannot be single item list (#5545) Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"]. This would cause an issue in ExtractReview later. --- .../plugins/publish/validate_expected_and_rendered_files.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 9f1f7bc518..5d37e7357e 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -70,7 +70,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(job_expected_files) + # single item files must be string not list + repre["files"] = (sorted(job_expected_files) + if len(job_expected_files) > 1 else + list(job_expected_files)[0]) # Update the expected files expected_files = job_expected_files From 4ed278c0c885e22fdcf97c9a1b242428fee3ff05 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 12:18:03 +0300 Subject: [PATCH 264/429] BigRoy's comment --- .../houdini/plugins/publish/validate_review_colorspace.py | 3 +-- openpype/scripts/ocio_wrapper.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index d457a295c5..545d7b16b1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -83,9 +83,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, default_view_space = get_default_display_view_colorspace() rop_node.setParms({"ociocolorspace": default_view_space}) - cls.log.debug( + cls.log.info( "'OCIO Colorspace' parm on '{}' has been set to " "the default view color space '{}'" .format(rop_node, default_view_space) - ) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 2c11bb7eeb..40553d30f2 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -195,7 +195,7 @@ def _get_display_view_colorspace_name(config_path, display, view): if not config_path.is_file(): raise IOError("Input path should be `config.ocio` file") - config = ocio.Config().CreateFromFile(str(config_path)) + config = ocio.Config.CreateFromFile(str(config_path)) colorspace = config.getDisplayViewColorSpaceName(display, view) return colorspace From 35074e8db892d07bfd43ea3860e660ab10843346 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 4 Sep 2023 11:49:14 +0200 Subject: [PATCH 265/429] Fix versions in deprecated message --- openpype/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index bd9d1d16c3..de9cc7cff3 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -221,7 +221,7 @@ class BaseCreator: " System settings argument will not be passed to" " '__init__' and 'apply_settings' methods in future versions" " of OpenPype. Planned version to drop the support" - " is 3.15.6 or 3.16.0. Please contact Ynput core team if you" + " is 3.16.6 or 3.17.0. Please contact Ynput core team if you" " need to keep system settings." ).format(self.__class__.__name__)) From 0c8ad276e409840ce665ecd7c8c3655a545cc500 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 18:02:32 +0800 Subject: [PATCH 266/429] Oscar's and BigRoy's comment respectively on namespace function --- openpype/hosts/max/api/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 034307e72a..8287341456 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -316,7 +316,6 @@ def set_timeline(frameStart, frameEnd): def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): - from pymxs import runtime as rt """Return unique namespace Arguments: @@ -336,7 +335,7 @@ def unique_namespace(namespace, format="%02d", def current_namespace(): current = namespace - # When inside a namespace Maya adds no trailing : + # When inside a namespace Max adds no trailing : if not current.endswith(":"): current += ":" return current From df2466b714f86336fad823bd9699aced271e5457 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Sep 2023 12:58:31 +0200 Subject: [PATCH 267/429] default fps for sequence from asset instead of project --- openpype/plugins/publish/collect_sequence_frame_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index 241e7b9011..6c2bfbf358 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -52,5 +52,5 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): "frameEnd": repres_frames[-1], "handleStart": 0, "handleEnd": 0, - "fps": instance.context.data["projectEntity"]["data"]["fps"] + "fps": instance.context.data["assetEntity"]["data"]["fps"] } From 378ec74136eba325cba019348cdc69cb90c7d6b6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Sep 2023 12:59:06 +0200 Subject: [PATCH 268/429] validator for frame range should include Plate family --- .../traypublisher/plugins/publish/validate_frame_ranges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py index b962ea464a..09de2d8db2 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py @@ -15,7 +15,7 @@ class ValidateFrameRange(OptionalPyblishPluginMixin, label = "Validate Frame Range" hosts = ["traypublisher"] - families = ["render"] + families = ["render", "plate"] order = ValidateContentsOrder optional = True From ab019e312441af63b447f74d8e6119bce0ebb06e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Sep 2023 13:01:29 +0200 Subject: [PATCH 269/429] name of plugin should be more explicit --- ....py => collect_missing_frame_range_asset_entity.py} | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) rename openpype/hosts/traypublisher/plugins/publish/{collect_frame_range_asset_entity.py => collect_missing_frame_range_asset_entity.py} (83%) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py similarity index 83% rename from openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py rename to openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py index c18e10e438..72379ea4e1 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py @@ -2,16 +2,18 @@ import pyblish.api from openpype.pipeline import OptionalPyblishPluginMixin -class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Collect Frame Range data From Asset Entity +class CollectMissingFrameDataFromAssetEntity( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Collect Missing Frame Range data From Asset Entity Frame range data will only be collected if the keys are not yet collected for the instance. """ order = pyblish.api.CollectorOrder + 0.491 - label = "Collect Frame Data From Asset Entity" + label = "Collect Missing Frame Data From Asset Entity" families = ["plate", "pointcache", "vdbcache", "online", "render"] From 646d4f6db2850a3faefb32e5635074ae84cd44c3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 19:19:39 +0800 Subject: [PATCH 270/429] oscar comment on the import custom attribute data --- openpype/hosts/max/api/pipeline.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 161e2bdc7b..695169894f 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -197,19 +197,18 @@ def import_custom_attribute_data(container: str, selections: list): rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" rt.custAttributes.add(container.modifiers[0], attrs) - node_list = [] - sel_list = [] + nodes = {} for i in selections: - node_ref = rt.NodeTransformMonitor(node=i) - node_list.append(node_ref) - sel_list.append(str(i)) + nodes = { + str(i) : rt.NodeTransformMonitor(node=i), + } # Setting the property rt.setProperty( container.modifiers[0].openPypeData, - "all_handles", node_list) + "all_handles", nodes.values()) rt.setProperty( container.modifiers[0].openPypeData, - "sel_list", sel_list) + "sel_list", nodes.keys()) def update_custom_attribute_data(container: str, selections: list): From 4e3d0d7eacc63e2ac4892cecbcefc595905df933 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 19:20:42 +0800 Subject: [PATCH 271/429] hound --- openpype/hosts/max/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 695169894f..d9a66c60f5 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -200,7 +200,7 @@ def import_custom_attribute_data(container: str, selections: list): nodes = {} for i in selections: nodes = { - str(i) : rt.NodeTransformMonitor(node=i), + str(i): rt.NodeTransformMonitor(node=i), } # Setting the property rt.setProperty( From 3c911f9f5d2995e866fa7f0b5fe41c6eff100bef Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 13:24:07 +0200 Subject: [PATCH 272/429] removed export maya ass job script --- openpype/scripts/export_maya_ass_job.py | 105 ------------------ openpype/scripts/export_maya_ass_sequence.mel | 67 ----------- 2 files changed, 172 deletions(-) delete mode 100644 openpype/scripts/export_maya_ass_job.py delete mode 100644 openpype/scripts/export_maya_ass_sequence.mel diff --git a/openpype/scripts/export_maya_ass_job.py b/openpype/scripts/export_maya_ass_job.py deleted file mode 100644 index 16e841ce96..0000000000 --- a/openpype/scripts/export_maya_ass_job.py +++ /dev/null @@ -1,105 +0,0 @@ -"""This module is used for command line exporting of ASS files. - -WARNING: -This need to be rewriten to be able use it in Pype 3! -""" - -import os -import argparse -import logging -import subprocess -import platform - -try: - from shutil import which -except ImportError: - # we are in python < 3.3 - def which(command): - path = os.getenv('PATH') - for p in path.split(os.path.pathsep): - p = os.path.join(p, command) - if os.path.exists(p) and os.access(p, os.X_OK): - return p - -handler = logging.basicConfig() -log = logging.getLogger("Publish Image Sequences") -log.setLevel(logging.DEBUG) - -error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" - - -def __main__(): - parser = argparse.ArgumentParser() - parser.add_argument("--paths", - nargs="*", - default=[], - help="The filepaths to publish. This can be a " - "directory or a path to a .json publish " - "configuration.") - parser.add_argument("--gui", - default=False, - action="store_true", - help="Whether to run Pyblish in GUI mode.") - - parser.add_argument("--pype", help="Pype root") - - kwargs, args = parser.parse_known_args() - - print("Running pype ...") - auto_pype_root = os.path.dirname(os.path.abspath(__file__)) - auto_pype_root = os.path.abspath(auto_pype_root + "../../../../..") - - auto_pype_root = os.environ.get('OPENPYPE_SETUP_PATH') or auto_pype_root - if os.environ.get('OPENPYPE_SETUP_PATH'): - print("Got Pype location from environment: {}".format( - os.environ.get('OPENPYPE_SETUP_PATH'))) - - pype_command = "openpype.ps1" - if platform.system().lower() == "linux": - pype_command = "pype" - elif platform.system().lower() == "windows": - pype_command = "openpype.bat" - - if kwargs.pype: - pype_root = kwargs.pype - else: - # test if pype.bat / pype is in the PATH - # if it is, which() will return its path and we use that. - # if not, we use auto_pype_root path. Caveat of that one is - # that it can be UNC path and that will not work on windows. - - pype_path = which(pype_command) - - if pype_path: - pype_root = os.path.dirname(pype_path) - else: - pype_root = auto_pype_root - - print("Set pype root to: {}".format(pype_root)) - print("Paths: {}".format(kwargs.paths or [os.getcwd()])) - - # paths = kwargs.paths or [os.environ.get("OPENPYPE_METADATA_FILE")] or [os.getcwd()] # noqa - - mayabatch = os.environ.get("AVALON_APP_NAME").replace("maya", "mayabatch") - args = [ - os.path.join(pype_root, pype_command), - "launch", - "--app", - mayabatch, - "-script", - os.path.join(pype_root, "repos", "pype", - "pype", "scripts", "export_maya_ass_sequence.mel") - ] - - print("Pype command: {}".format(" ".join(args))) - # Forcing forwaring the environment because environment inheritance does - # not always work. - # Cast all values in environment to str to be safe - env = {k: str(v) for k, v in os.environ.items()} - exit_code = subprocess.call(args, env=env) - if exit_code != 0: - raise RuntimeError("Publishing failed.") - - -if __name__ == '__main__': - __main__() diff --git a/openpype/scripts/export_maya_ass_sequence.mel b/openpype/scripts/export_maya_ass_sequence.mel deleted file mode 100644 index b3b9a8543e..0000000000 --- a/openpype/scripts/export_maya_ass_sequence.mel +++ /dev/null @@ -1,67 +0,0 @@ -/* - Script to export specified layer as ass files. - -Attributes: - - scene_file (str): Name of the scene to load. - start (int): Start frame. - end (int): End frame. - step (int): Step size. - output_path (str): File output path. - render_layer (str): Name of render layer. - -*/ - -$scene_file=`getenv "OPENPYPE_ASS_EXPORT_SCENE_FILE"`; -$step=`getenv "OPENPYPE_ASS_EXPORT_STEP"`; -$start=`getenv "OPENPYPE_ASS_EXPORT_START"`; -$end=`getenv "OPENPYPE_ASS_EXPORT_END"`; -$file_path=`getenv "OPENPYPE_ASS_EXPORT_OUTPUT"`; -$render_layer = `getenv "OPENPYPE_ASS_EXPORT_RENDER_LAYER"`; - -print("*** ASS Export Plugin\n"); - -if ($scene_file == "") { - print("!!! cannot determine scene file\n"); - quit -a -ex -1; -} - -if ($step == "") { - print("!!! cannot determine step size\n"); - quit -a -ex -1; -} - -if ($start == "") { - print("!!! cannot determine start frame\n"); - quit -a -ex -1; -} - -if ($end == "") { - print("!!! cannot determine end frame\n"); - quit -a -ex -1; -} - -if ($file_path == "") { - print("!!! cannot determine output file\n"); - quit -a -ex -1; -} - -if ($render_layer == "") { - print("!!! cannot determine render layer\n"); - quit -a -ex -1; -} - - -print(">>> Opening Scene [ " + $scene_file + " ]\n"); - -// open scene -file -o -f $scene_file; - -// switch to render layer -print(">>> Switching layer [ "+ $render_layer + " ]\n"); -editRenderLayerGlobals -currentRenderLayer $render_layer; - -// export -print(">>> Exporting to [ " + $file_path + " ]\n"); -arnoldExportAss -mask 255 -sl 1 -ll 1 -bb 1 -sf $start -se $end -b -fs $step; -print("--- Done\n"); From aa9846dcbde067e63aa2089752b76840417870bb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 13:25:35 +0200 Subject: [PATCH 273/429] removed logic where the script is used --- .../plugins/publish/submit_maya_deadline.py | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 34f3905a17..24052f23d5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -334,12 +334,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, payload = self._get_vray_render_payload(payload_data) - elif "assscene" in instance.data["families"]: - self.log.debug("Submitting Arnold .ass standalone render..") - ass_export_payload = self._get_arnold_export_payload(payload_data) - export_job = self.submit(ass_export_payload) - - payload = self._get_arnold_render_payload(payload_data) else: self.log.debug("Submitting MayaBatch render..") payload = self._get_maya_payload(payload_data) @@ -635,53 +629,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info, attr.asdict(plugin_info) - def _get_arnold_export_payload(self, data): - - try: - from openpype.scripts import export_maya_ass_job - except Exception: - raise AssertionError( - "Expected module 'export_maya_ass_job' to be available") - - module_path = export_maya_ass_job.__file__ - if module_path.endswith(".pyc"): - module_path = module_path[: -len(".pyc")] + ".py" - - script = os.path.normpath(module_path) - - job_info = copy.deepcopy(self.job_info) - job_info.Name = self._job_info_label("Export") - - # Force a single frame Python job - job_info.Plugin = "Python" - job_info.Frames = 1 - - renderlayer = self._instance.data["setMembers"] - - # add required env vars for the export script - envs = { - "AVALON_APP_NAME": os.environ.get("AVALON_APP_NAME"), - "OPENPYPE_ASS_EXPORT_RENDER_LAYER": renderlayer, - "OPENPYPE_ASS_EXPORT_SCENE_FILE": self.scene_path, - "OPENPYPE_ASS_EXPORT_OUTPUT": job_info.OutputFilename[0], - "OPENPYPE_ASS_EXPORT_START": int(self._instance.data["frameStartHandle"]), # noqa - "OPENPYPE_ASS_EXPORT_END": int(self._instance.data["frameEndHandle"]), # noqa - "OPENPYPE_ASS_EXPORT_STEP": 1 - } - for key, value in envs.items(): - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - - plugin_info = PythonPluginInfo( - ScriptFile=script, - Version="3.6", - Arguments="", - SingleFrameOnly="True" - ) - - return job_info, attr.asdict(plugin_info) - def _get_vray_render_payload(self, data): # Job Info From 90bfd0a79a8ef5fcee474340594949e44f657ab2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 13:26:36 +0200 Subject: [PATCH 274/429] Yeti Cache: Include viewport preview settings --- .../plugins/publish/collect_yeti_cache.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py index e6b5ca4260..4dcda29050 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py @@ -4,12 +4,23 @@ import pyblish.api from openpype.hosts.maya.api import lib -SETTINGS = {"renderDensity", - "renderWidth", - "renderLength", - "increaseRenderBounds", - "imageSearchPath", - "cbId"} + +SETTINGS = { + # Preview + "displayOutput", + "colorR", "colorG", "colorB", + "viewportDensity", + "viewportWidth", + "viewportLength", + # Render attributes + "renderDensity", + "renderWidth", + "renderLength", + "increaseRenderBounds", + "imageSearchPath", + # Pipeline specific + "cbId" +} class CollectYetiCache(pyblish.api.InstancePlugin): @@ -39,10 +50,6 @@ class CollectYetiCache(pyblish.api.InstancePlugin): # Get yeti nodes and their transforms yeti_shapes = cmds.ls(instance, type="pgYetiMaya") for shape in yeti_shapes: - shape_data = {"transform": None, - "name": shape, - "cbId": lib.get_id(shape), - "attrs": None} # Get specific node attributes attr_data = {} @@ -58,9 +65,12 @@ class CollectYetiCache(pyblish.api.InstancePlugin): parent = cmds.listRelatives(shape, parent=True)[0] transform_data = {"name": parent, "cbId": lib.get_id(parent)} - # Store collected data - shape_data["attrs"] = attr_data - shape_data["transform"] = transform_data + shape_data = { + "transform": transform_data, + "name": shape, + "cbId": lib.get_id(shape), + "attrs": attr_data, + } settings["nodes"].append(shape_data) From 26ef5812b7428f792e81de4a2bfa61867e73f834 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 13:42:01 +0200 Subject: [PATCH 275/429] Skip viewport attributes on 'update' but preserve what artist tweaked after initial load --- .../hosts/maya/plugins/load/load_yeti_cache.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index 5cded13d4e..4a11ea9a2c 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -15,6 +15,16 @@ from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise +# Do not reset these values on update but only apply on first load +# to preserve any potential local overrides +SKIP_UPDATE_ATTRS = { + "displayOutput", + "viewportDensity", + "viewportWidth", + "viewportLength", +} + + def set_attribute(node, attr, value): """Wrapper of set attribute which ignores None values""" if value is None: @@ -205,6 +215,8 @@ class YetiCacheLoader(load.LoaderPlugin): yeti_node = yeti_nodes[0] for attr, value in node_settings["attrs"].items(): + if attr in SKIP_UPDATE_ATTRS: + continue set_attribute(attr, value, yeti_node) cmds.setAttr("{}.representation".format(container_node), @@ -311,7 +323,6 @@ class YetiCacheLoader(load.LoaderPlugin): # Update attributes with defaults attributes = node_settings["attrs"] attributes.update({ - "viewportDensity": 0.1, "verbosity": 2, "fileMode": 1, @@ -321,6 +332,9 @@ class YetiCacheLoader(load.LoaderPlugin): "visibleInRefractions": True }) + if "viewportDensity" not in attributes: + attributes["viewportDensity"] = 0.1 + # Apply attributes to pgYetiMaya node for attr, value in attributes.items(): set_attribute(attr, value, yeti_node) From ad62e1fd469fe122b9b7490a5d68b49db26741b2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 15:06:42 +0300 Subject: [PATCH 276/429] revise collect-validators-extract --- .../hosts/houdini/plugins/load/load_fbx.py | 21 ++---- .../publish/collect_staticmesh_type.py | 2 +- .../houdini/plugins/publish/extract_fbx.py | 13 +--- .../publish/validate_mesh_is_static.py | 70 +++++++++++++++++++ .../plugins/publish/validate_output_node.py | 55 +++++++++++++++ .../publish/validate_sop_output_node.py | 2 +- .../validate_unreal_staticmesh_naming.py | 9 ++- 7 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_output_node.py diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 681837e046..9c7dbf578e 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -1,12 +1,5 @@ # -*- coding: utf-8 -*- -"""Fbx Loader for houdini. - -It's almost a copy of -'load_bgeo.py'and 'load_alembic.py' -however this one includes extra comments for demonstration. - -This plugin is part of publish process guide. -""" +"""Fbx Loader for houdini. """ from openpype.pipeline import ( load, get_representation_path, @@ -15,17 +8,17 @@ from openpype.hosts.houdini.api import pipeline class FbxLoader(load.LoaderPlugin): - """Load fbx files to Houdini.""" + """Load fbx files. """ label = "Load FBX" - families = ["staticMesh", "fbx"] - representations = ["fbx"] - - # Usually you will use these value as default - order = -10 icon = "code-fork" color = "orange" + order = -10 + + families = ["staticMesh", "fbx"] + representations = ["fbx"] + def load(self, context, name=None, namespace=None, data=None): # get file path diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py index 8fb07c1c5c..263d7c1001 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -16,5 +16,5 @@ class CollectStaticMeshType(pyblish.api.InstancePlugin): def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 - # Marking this instance as FBX which triggers the FBX extractor. + # Marking this instance as FBX triggers the FBX extractor. instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index c0e84c00c8..2a95734ece 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -1,15 +1,8 @@ -"""Extract FilmBox FBX. - -Extractors are used to generate output and -update representation dictionary. - -This plugin is part of publish process guide. -""" +# -*- coding: utf-8 -*- +"""Fbx Extractor for houdini. """ import os - import pyblish.api - from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop @@ -22,10 +15,8 @@ class ExtractFBX(publish.Extractor): families = ["fbx"] hosts = ["houdini"] - # Usually you will use this value as default order = pyblish.api.ExtractorOrder + 0.1 - # overrides Extractor.process() def process(self, instance): # get rop node diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py new file mode 100644 index 0000000000..90985b4239 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ValidateContentsOrder + +from openpype.hosts.houdini.api.action import SelectInvalidAction + +import hou + + +class ValidateMeshIsStatic(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate mesh is static. + + It checks if output node is time dependant. + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate Mesh is Static" + order = ValidateContentsOrder + 0.1 + actions = [SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + output_node = instance.data.get("output_node") + if output_node is None: + cls.log.debug( + "No Output Node, skipping check.." + ) + return + + + + if output_node.name().isTimeDependent(): + invalid.append(output_node) + cls.log.error( + "Output node '%s' is time dependent.", + output_node.name() + ) + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + if output_node.name().isTimeDependent(): + invalid.append(child) + cls.log.error( + "Child node '%s' in '%s' " + "his time dependent.", + child.name(), output_node.path() + ) + break + + return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_output_node.py new file mode 100644 index 0000000000..99a6cda077 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_output_node.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou + + +class ValidateOutputNode(pyblish.api.InstancePlugin): + """Validate the instance Output Node. + + This will ensure: + - The Output Node Path is set. + - The Output Node Path refers to an existing object. + """ + + order = pyblish.api.ValidatorOrder + families = ["fbx"] + hosts = ["houdini"] + label = "Validate Output Node" + actions = [SelectROPAction, SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Output node(s) are incorrect", + title="Invalid output node(s)" + ) + + @classmethod + def get_invalid(cls, instance): + output_node = instance.data.get("output_node") + + if output_node is None: + rop_node = hou.node(instance.data["instance_node"]) + cls.log.error( + "Output node in '%s' does not exist. " + "Ensure a valid output path is set.", rop_node.path() + ) + + return [rop_node] + + if output_node.type().category().name() not in ["Sop", "Object"]: + cls.log.error( + "Output node %s is not a SOP or OBJ node. " + "It must point to a SOP or OBJ node, " + "instead found category type: %s" + % (output_node.path(), output_node.type().category().name()) + ) + return [output_node] diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index d9dee38680..9590e37d26 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -24,7 +24,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["pointcache", "vdbcache"] hosts = ["houdini"] - label = "Validate Output Node" + label = "Validate Output Node (SOP)" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 7820be4009..f1ea9b3844 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -53,8 +53,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] raise PublishValidationError( "See log for details. " - "Invalid nodes: {0}".format(nodes), - title=self.label + "Invalid nodes: {0}".format(nodes) ) @classmethod @@ -64,7 +63,11 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, rop_node = hou.node(instance.data["instance_node"]) output_node = instance.data.get("output_node") - cls.log.debug(cls.collision_prefixes) + if output_node is None: + cls.log.debug( + "No Output Node, skipping check.." + ) + return # Check nodes names if output_node.childTypeCategory() == hou.objNodeTypeCategory(): From 8440d65949e76dd9aca42206df528a62625ea32b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 20:32:16 +0800 Subject: [PATCH 277/429] update on the ornatrix laoder --- openpype/hosts/max/api/pipeline.py | 51 ------------------- .../plugins/load/load_pointcache_ornatrix.py | 46 ++++++++++++++--- 2 files changed, 38 insertions(+), 59 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index d9a66c60f5..2ce96d16e1 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -15,10 +15,8 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.menu import OpenPypeMenu from openpype.hosts.max.api import lib -from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB from openpype.hosts.max import MAX_HOST_DIR - from pymxs import runtime as rt # noqa log = logging.getLogger("openpype.hosts.max") @@ -173,52 +171,3 @@ def containerise(name: str, nodes: list, context, if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container - - -def load_custom_attribute_data(): - """Re-loading the Openpype/AYON custom parameter built by the creator - - Returns: - attribute: re-loading the custom OP attributes set in Maxscript - """ - return rt.Execute(MS_CUSTOM_ATTRIB) - - -def import_custom_attribute_data(container: str, selections: list): - """Importing the Openpype/AYON custom parameter built by the creator - - Args: - container (str): target container which adds custom attributes - selections (list): nodes to be added into - group in custom attributes - """ - attrs = load_custom_attribute_data() - modifier = rt.EmptyModifier() - rt.addModifier(container, modifier) - container.modifiers[0].name = "OP Data" - rt.custAttributes.add(container.modifiers[0], attrs) - nodes = {} - for i in selections: - nodes = { - str(i): rt.NodeTransformMonitor(node=i), - } - # Setting the property - rt.setProperty( - container.modifiers[0].openPypeData, - "all_handles", nodes.values()) - rt.setProperty( - container.modifiers[0].openPypeData, - "sel_list", nodes.keys()) - - -def update_custom_attribute_data(container: str, selections: list): - """Updating the Openpype/AYON custom parameter built by the creator - - Args: - container (str): target container which adds custom attributes - selections (list): nodes to be added into - group in custom attributes - """ - if container.modifiers[0].name == "OP Data": - rt.deleteModifier(container, container.modifiers[0]) - import_custom_attribute_data(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 56cac00aeb..d3b7c61ff8 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -1,6 +1,15 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, + import_custom_attribute_data, + update_custom_attribute_data +) +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + object_transform_set +) from openpype.hosts.max.api import lib from pymxs import runtime as rt @@ -14,6 +23,7 @@ class OxAbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): plugin_list = get_plugins() @@ -34,21 +44,37 @@ class OxAbcLoader(load.LoaderPlugin): if str(obj_type).startswith("Ox_"): scene_object.append(obj) - abc_container = rt.Container(name=name) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + + abc_container = rt.Container() for abc in scene_object: abc.Parent = abc_container + abc.name = f"{namespace}:{abc.name}" + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}_{self.postfix}" + abc_container.name = abc_container_name + import_custom_attribute_data( + abc_container, abc_container.Children) return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, [abc_container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): path = get_representation_path(representation) node_name = container["instance_node"] - instance_name, _ = os.path.splitext(node_name) - container = rt.getNodeByName(instance_name) - for children in container.Children: - rt.Delete(children) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}_{self.postfix}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) + for prev_obj in rt.selection: + if rt.isValidNode(prev_obj): + rt.Delete(prev_obj) rt.AlembicImport.ImportToRoot = False rt.AlembicImport.CustomAttributes = True @@ -61,9 +87,13 @@ class OxAbcLoader(load.LoaderPlugin): obj_type = rt.ClassOf(obj) if str(obj_type).startswith("Ox_"): scene_object.append(obj) - + update_custom_attribute_data( + inst_container, scene_object.Children) for abc in scene_object: abc.Parent = container + abc.name = f"{namespace}:{abc.name}" + abc.pos = transform_data[f"{abc.name}.transform"] + abc.scale = transform_data[f"{abc.name}.scale"] lib.imprint( container["instance_node"], From c2722344e052565413914a6e7118024d03dee745 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 20:38:18 +0800 Subject: [PATCH 278/429] update attribute should be correct --- openpype/hosts/max/api/pipeline.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index d9a66c60f5..72163f5ecf 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -197,19 +197,20 @@ def import_custom_attribute_data(container: str, selections: list): rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" rt.custAttributes.add(container.modifiers[0], attrs) - nodes = {} + node_list = [] + sel_list = [] for i in selections: - nodes = { - str(i): rt.NodeTransformMonitor(node=i), - } + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + sel_list.append(str(i)) + # Setting the property rt.setProperty( container.modifiers[0].openPypeData, - "all_handles", nodes.values()) + "all_handles", node_list) rt.setProperty( container.modifiers[0].openPypeData, - "sel_list", nodes.keys()) - + "sel_list", sel_list) def update_custom_attribute_data(container: str, selections: list): """Updating the Openpype/AYON custom parameter built by the creator From 6ef67e3ff928fca53f4f10ea3166474d5b59c8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Mon, 4 Sep 2023 17:17:15 +0200 Subject: [PATCH 279/429] Update openpype/pipeline/colorspace.py Co-authored-by: Roy Nieterau --- openpype/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index b7728936b0..ce0835dcc6 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -20,7 +20,7 @@ log = Logger.get_logger(__name__) class CachedData: - remapping = None + remapping = {} allowed_exts = { ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) } From 1678bd56032e8975301651c4bcdfb6ae1290dce6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 19:48:15 +0300 Subject: [PATCH 280/429] update fetch output --- .../validate_unreal_staticmesh_naming.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index f1ea9b3844..3c13f081a9 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -107,3 +107,30 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return invalid + + def get_outputs(self, output_node): + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + out_list = [output_node] + for child in output_node.children(): + out_list += self.get_outputs(child) + + return out_list + + elif output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + return [output_node, self.get_obj_output(output_node)] + + def get_obj_output(self, obj_node): + """Find sop output node, """ + + outputs = obj_node.subnetOutputs() + + if not outputs: + return + + elif len(outputs) == 1: + return outputs[0] + + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) From 2fdaadcdc151918163477f3275b646a7044f43e6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 19:50:00 +0300 Subject: [PATCH 281/429] Minikiu comment --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 545d7b16b1..61c3a755d0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -39,7 +39,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, if os.getenv("OCIO") is None: self.log.debug( - "Default Houdini colorspace is used, " + "Using Houdini's Default Color Management, " " skipping check.." ) return From 79aab2534ea53931980b4bb44e52713047898c65 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 21:59:04 +0300 Subject: [PATCH 282/429] update retrieving output nodes --- openpype/hosts/houdini/api/lib.py | 39 ++++++++++++ .../publish/validate_mesh_is_static.py | 27 +++----- .../validate_unreal_staticmesh_naming.py | 62 +++++-------------- 3 files changed, 65 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 75c7ff9fee..0f1cfe0717 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -649,3 +649,42 @@ def get_color_management_preferences(): "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } + + +def get_obj_node_output(obj_node): + """Find output node. + + get the output node with the minimum 'outputidx' + or the node with display flag. + """ + + outputs = obj_node.subnetOutputs() + if not outputs: + return + + elif len(outputs) == 1: + return outputs[0] + + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) + + +def get_output_children(output_node, include_sops=True): + """Recursively return a list of all output nodes + contained in this node including this node. + + It works in a similar manner to output_node.allNodes(). + """ + out_list = [output_node] + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + out_list += get_output_children(child, include_sops=include_sops) + + elif include_sops and output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + out = get_obj_node_output(output_node) + if out: + out_list += [out] + + return out_list diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 90985b4239..36c8ef6d63 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.hosts.houdini.api.lib import get_output_children import hou @@ -47,24 +48,14 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin, ) return + all_outputs = get_output_children(output_node) - - if output_node.name().isTimeDependent(): - invalid.append(output_node) - cls.log.error( - "Output node '%s' is time dependent.", - output_node.name() - ) - - if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - for child in output_node.children(): - if output_node.name().isTimeDependent(): - invalid.append(child) - cls.log.error( - "Child node '%s' in '%s' " - "his time dependent.", - child.name(), output_node.path() - ) - break + for output in all_outputs: + if output.isTimeDependent(): + invalid.append(output) + cls.log.error( + "Output node '%s' is time dependent.", + output.path() + ) return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 3c13f081a9..5558b43258 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.hosts.houdini.api.lib import get_output_children import hou @@ -69,28 +70,26 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return + if not rop_node.evalParm('buildfrompath'): + # This validator doesn't support naming check if + # building hierarchy from path' is used + cls.log.info( + "Using 'Build Hierarchy from Path Attribute', skipping check.." + ) + return + # Check nodes names - if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - for child in output_node.children(): - for prefix in cls.collision_prefixes: - if child.name().startswith(prefix): - invalid.append(child) - cls.log.error( - "Invalid name: Child node '%s' in '%s' " - "has a collision prefix '%s'", - child.name(), output_node.path(), prefix - ) - break - else: - cls.log.debug(output_node.name()) + all_outputs = get_output_children(output_node, include_sops=False) + for output in all_outputs: for prefix in cls.collision_prefixes: - if output_node.name().startswith(prefix): - invalid.append(output_node) + if output.name().startswith(prefix): + invalid.append(output) cls.log.error( - "Invalid name: output node '%s' " - "has a collision prefix '%s'", - output_node.name(), prefix + "Invalid node name: Node '%s' " + "includes a collision prefix '%s'", + output.path(), prefix ) + break # Check subset name subset_name = "{}_{}{}".format( @@ -107,30 +106,3 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return invalid - - def get_outputs(self, output_node): - - if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - out_list = [output_node] - for child in output_node.children(): - out_list += self.get_outputs(child) - - return out_list - - elif output_node.childTypeCategory() == hou.sopNodeTypeCategory(): - return [output_node, self.get_obj_output(output_node)] - - def get_obj_output(self, obj_node): - """Find sop output node, """ - - outputs = obj_node.subnetOutputs() - - if not outputs: - return - - elif len(outputs) == 1: - return outputs[0] - - else: - return min(outputs, - key=lambda node: node.evalParm('outputidx')) From 9ea11e7f2d90faf25487ebe2f8f7db4d461da018 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 22:02:23 +0300 Subject: [PATCH 283/429] resolve hound --- openpype/hosts/houdini/api/lib.py | 7 ++++--- .../houdini/plugins/publish/validate_mesh_is_static.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 0f1cfe0717..3e51912c26 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -667,7 +667,7 @@ def get_obj_node_output(obj_node): else: return min(outputs, - key=lambda node: node.evalParm('outputidx')) + key=lambda node: node.evalParm('outputidx')) def get_output_children(output_node, include_sops=True): @@ -682,8 +682,9 @@ def get_output_children(output_node, include_sops=True): for child in output_node.children(): out_list += get_output_children(child, include_sops=include_sops) - elif include_sops and output_node.childTypeCategory() == hou.sopNodeTypeCategory(): - out = get_obj_node_output(output_node) + elif include_sops and \ + output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + out = get_obj_node_output(output_node) if out: out_list += [out] diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 36c8ef6d63..25ab362a88 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -14,7 +14,7 @@ import hou class ValidateMeshIsStatic(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): + OptionalPyblishPluginMixin): """Validate mesh is static. It checks if output node is time dependant. From 6c1385e2c94e263f9186609c272ab7c0503485d9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 23:49:54 +0200 Subject: [PATCH 284/429] Inherit from correct new style creator --- .../hosts/maya/plugins/create/create_multiverse_usd_over.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py index e1534dd68c..166dbf6515 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -6,7 +6,7 @@ from openpype.lib import ( ) -class CreateMultiverseUsdOver(plugin.Creator): +class CreateMultiverseUsdOver(plugin.MayaCreator): """Create Multiverse USD Override""" identifier = "io.openpype.creators.maya.mvusdoverride" From 167527f2bf1b8fed25f38d84d08f0edca6bb718c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 23:55:41 +0200 Subject: [PATCH 285/429] Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument --- openpype/hosts/houdini/api/pipeline.py | 48 ++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 3c325edfa7..c9ae801af5 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -303,6 +303,28 @@ def on_save(): lib.set_id(node, new_id, overwrite=False) +def _show_outdated_content_popup(): + # Get main window + parent = lib.get_main_window() + if parent is None: + log.info("Skipping outdated content pop-up " + "because Houdini window can't be found.") + else: + from openpype.widgets import popup + + # Show outdated pop-up + def _on_show_inventory(): + from openpype.tools.utils import host_tools + host_tools.show_scene_inventory(parent=parent) + + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Houdini scene has outdated content") + dialog.setMessage("There are outdated containers in " + "your Houdini scene.") + dialog.on_clicked.connect(_on_show_inventory) + dialog.show() + + def on_open(): if not hou.isUIAvailable(): @@ -316,28 +338,18 @@ def on_open(): lib.validate_fps() if any_outdated_containers(): - from openpype.widgets import popup - - log.warning("Scene has outdated content.") - - # Get main window parent = lib.get_main_window() if parent is None: - log.info("Skipping outdated content pop-up " - "because Houdini window can't be found.") + # When opening Houdini with last workfile on launch the UI hasn't + # initialized yet completely when the `on_open` callback triggers. + # We defer the dialog popup to wait for the UI to become available. + # We assume it will open because `hou.isUIAvailable()` returns True + import hdefereval + hdefereval.executeDeferred(_show_outdated_content_popup) else: + _show_outdated_content_popup() - # Show outdated pop-up - def _on_show_inventory(): - from openpype.tools.utils import host_tools - host_tools.show_scene_inventory(parent=parent) - - dialog = popup.Popup(parent=parent) - dialog.setWindowTitle("Houdini scene has outdated content") - dialog.setMessage("There are outdated containers in " - "your Houdini scene.") - dialog.on_clicked.connect(_on_show_inventory) - dialog.show() + log.warning("Scene has outdated content.") def on_new(): From 7c29aa2f7b72dd0a94a5a3531bc8a65c6d1cd3bb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 00:00:03 +0200 Subject: [PATCH 286/429] Improve errors e.g. raise PublishValidationError or cosmetics --- .../houdini/plugins/publish/collect_output_node.py | 6 ++++-- .../plugins/publish/validate_animation_settings.py | 3 ++- .../plugins/publish/validate_remote_publish.py | 11 +++++------ .../publish/validate_usd_render_product_names.py | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_output_node.py b/openpype/hosts/houdini/plugins/publish/collect_output_node.py index 601ed17b39..0b27678ed0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/collect_output_node.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import KnownPublishError + class CollectOutputSOPPath(pyblish.api.InstancePlugin): """Collect the out node's SOP/COP Path value.""" @@ -58,8 +60,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): elif node_type == "Redshift_Proxy_Output": out_node = node.parm("RS_archive_sopPath").evalAsNode() else: - raise ValueError( - "ROP node type '%s' is" " not supported." % node_type + raise KnownPublishError( + "ROP node type '{}' is not supported.".format(node_type) ) if not out_node: diff --git a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py index 4878738ed3..79387fbef5 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py +++ b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError from openpype.hosts.houdini.api import lib import hou @@ -30,7 +31,7 @@ class ValidateAnimationSettings(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( + raise PublishValidationError( "Output settings do no match for '%s'" % instance ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py b/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py index 4e8e5fc0e8..4f71d79382 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py +++ b/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py @@ -36,11 +36,11 @@ class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): if node.parm("shellexec").eval(): self.raise_error("Must not execute in shell") if node.parm("prerender").eval() != cmd: - self.raise_error(("REMOTE_PUBLISH node does not have " - "correct prerender script.")) + self.raise_error("REMOTE_PUBLISH node does not have " + "correct prerender script.") if node.parm("lprerender").eval() != "python": - self.raise_error(("REMOTE_PUBLISH node prerender script " - "type not set to 'python'")) + self.raise_error("REMOTE_PUBLISH node prerender script " + "type not set to 'python'") @classmethod def repair(cls, context): @@ -48,5 +48,4 @@ class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): lib.create_remote_publish_node(force=True) def raise_error(self, message): - self.log.error(message) - raise PublishValidationError(message, title=self.label) + raise PublishValidationError(message) diff --git a/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py b/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py index 02c44ab94e..1daa96f2b9 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py +++ b/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py @@ -24,7 +24,7 @@ class ValidateUSDRenderProductNames(pyblish.api.InstancePlugin): if not os.path.isabs(filepath): invalid.append( - "Output file path is not " "absolute path: %s" % filepath + "Output file path is not absolute path: %s" % filepath ) if invalid: From 0e580ad3190f9cfa15243ebde5e99f02e3c95e71 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 00:02:59 +0200 Subject: [PATCH 287/429] Fix invalid import `openpype.action` does not exist + raise KnownPublishError --- .../houdini/plugins/publish/increment_current_file.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/increment_current_file.py b/openpype/hosts/houdini/plugins/publish/increment_current_file.py index 2493b28bc1..3569de7693 100644 --- a/openpype/hosts/houdini/plugins/publish/increment_current_file.py +++ b/openpype/hosts/houdini/plugins/publish/increment_current_file.py @@ -2,7 +2,7 @@ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host -from openpype.action import get_errored_plugins_from_data +from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.hosts.houdini.api import HoudiniHost from openpype.pipeline.publish import KnownPublishError @@ -27,7 +27,7 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): def process(self, context): - errored_plugins = get_errored_plugins_from_data(context) + errored_plugins = get_errored_plugins_from_context(context) if any( plugin.__name__ == "HoudiniSubmitPublishDeadline" for plugin in errored_plugins @@ -40,9 +40,10 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): # Filename must not have changed since collecting host = registered_host() # type: HoudiniHost current_file = host.current_file() - assert ( - context.data["currentFile"] == current_file - ), "Collected filename mismatches from current scene name." + if context.data["currentFile"] != current_file: + raise KnownPublishError( + "Collected filename mismatches from current scene name." + ) new_filepath = version_up(current_file) host.save_workfile(new_filepath) From 10dd15444ad9244618e4e89e4a176d3aaaa2ef58 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:03:54 +0200 Subject: [PATCH 288/429] Log collected scene version as debug - artists don't care --- openpype/plugins/publish/collect_scene_version.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 70a0aca296..7920c1e82b 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -63,4 +63,6 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "filename: {}".format(filename)) context.data['version'] = int(version) - self.log.info('Scene Version: %s' % context.data.get('version')) + self.log.debug( + "Collected scene version: {}".format(context.data.get('version')) + ) From 43902c0b118019fc770affc9217a751cd6a98c07 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:04:13 +0200 Subject: [PATCH 289/429] Add debug log about the skipped input --- .../publish/collect_input_representations_to_versions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/plugins/publish/collect_input_representations_to_versions.py b/openpype/plugins/publish/collect_input_representations_to_versions.py index 54a3214647..2b8c745d3d 100644 --- a/openpype/plugins/publish/collect_input_representations_to_versions.py +++ b/openpype/plugins/publish/collect_input_representations_to_versions.py @@ -46,3 +46,10 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): version_id = representation_id_to_version_id.get(repre_id) if version_id: input_versions.append(version_id) + else: + self.log.debug( + "Representation id {} skipped because its version is " + "not found in current project. Likely it is loaded " + "from a library project or uses a deleted " + "representation or version.".format(repre_id) + ) From 97bb0de1a93c92e17be7ff31cc80c3a59cc8300e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:04:40 +0200 Subject: [PATCH 290/429] Raise KnownPublishError --- openpype/plugins/publish/extract_review_slate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 8b1a06b6b8..2ff7fbe7fa 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -14,10 +14,13 @@ from openpype.lib import ( get_ffmpeg_codec_args, get_ffmpeg_format_args, ) -from openpype.pipeline import publish +from openpype.pipeline.publish import ( + Extractor, + KnownPublishError +) -class ExtractReviewSlate(publish.Extractor): +class ExtractReviewSlate(Extractor): """ Will add slate frame at the start of the video files """ @@ -93,9 +96,10 @@ class ExtractReviewSlate(publish.Extractor): # Raise exception of any stream didn't define input resolution if input_width is None: - raise AssertionError(( + raise KnownPublishError( "FFprobe couldn't read resolution from input file: \"{}\"" - ).format(input_path)) + .format(input_path) + ) ( audio_codec, From 84960826a6250897e262f3c468aadc5a94d23c1a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:05:07 +0200 Subject: [PATCH 291/429] Tweak grammar + log what representations got created --- openpype/plugins/publish/integrate.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 0c18ab4466..7e48155b9e 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -306,7 +306,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # increase if the file transaction takes a long time. op_session.commit() - self.log.info("Subset '{subset[name]}' and Version {version[name]} " + self.log.info("Subset '{subset[name]}' version {version[name]} " "written to database..".format(subset=subset, version=version)) @@ -392,8 +392,13 @@ class IntegrateAsset(pyblish.api.InstancePlugin): p["representation"]["_id"]: p for p in prepared_representations } - self.log.info("Registered {} representations" - "".format(len(prepared_representations))) + self.log.info( + "Registered {} representations: {}".format( + len(prepared_representations), + ", ".join(p["representation"]["name"] + for p in prepared_representations) + ) + ) def prepare_subset(self, instance, op_session, project_name): asset_doc = instance.data["assetEntity"] From 4749ac80400346fd4b8ac27f3de4346ba526ba25 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:05:40 +0200 Subject: [PATCH 292/429] Raise PublishValidationError --- openpype/plugins/publish/validate_file_saved.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/validate_file_saved.py b/openpype/plugins/publish/validate_file_saved.py index 448eaccf57..94aadc9358 100644 --- a/openpype/plugins/publish/validate_file_saved.py +++ b/openpype/plugins/publish/validate_file_saved.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError + class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): """File must be saved before publishing""" @@ -12,4 +14,4 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): current_file = context.data["currentFile"] if not current_file: - raise RuntimeError("File not saved") + raise PublishValidationError("File not saved") From 68ab2fc934e6a11995884ad1472cb04567f7c271 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:06:07 +0200 Subject: [PATCH 293/429] Raise PublishValidationError --- .../plugins/publish/validate_filesequences.py | 5 ++++- openpype/plugins/publish/validate_intent.py | 10 ++++------ .../plugins/publish/validate_publish_dir.py | 17 +++++++++-------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/openpype/plugins/publish/validate_filesequences.py b/openpype/plugins/publish/validate_filesequences.py index 8a877d79bb..0ac281022d 100644 --- a/openpype/plugins/publish/validate_filesequences.py +++ b/openpype/plugins/publish/validate_filesequences.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError + class ValidateFileSequences(pyblish.api.ContextPlugin): """Validates whether any file sequences were collected.""" @@ -10,4 +12,5 @@ class ValidateFileSequences(pyblish.api.ContextPlugin): label = "Validate File Sequences" def process(self, context): - assert context, "Nothing collected." + if not context: + raise PublishValidationError("Nothing collected.") diff --git a/openpype/plugins/publish/validate_intent.py b/openpype/plugins/publish/validate_intent.py index 23d57bb2b7..832c7cc0a1 100644 --- a/openpype/plugins/publish/validate_intent.py +++ b/openpype/plugins/publish/validate_intent.py @@ -1,7 +1,7 @@ -import os import pyblish.api from openpype.lib import filter_profiles +from openpype.pipeline.publish import PublishValidationError class ValidateIntent(pyblish.api.ContextPlugin): @@ -51,12 +51,10 @@ class ValidateIntent(pyblish.api.ContextPlugin): )) return - msg = ( - "Please make sure that you select the intent of this publish." - ) - intent = context.data.get("intent") or {} self.log.debug(str(intent)) intent_value = intent.get("value") if not intent_value: - raise AssertionError(msg) + raise PublishValidationError( + "Please make sure that you select the intent of this publish." + ) diff --git a/openpype/plugins/publish/validate_publish_dir.py b/openpype/plugins/publish/validate_publish_dir.py index ad5fd34434..0eb93da583 100644 --- a/openpype/plugins/publish/validate_publish_dir.py +++ b/openpype/plugins/publish/validate_publish_dir.py @@ -47,15 +47,16 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): # original_dirname must be convertable to rootless path # in other case it is path inside of root folder for the project success, _ = anatomy.find_root_template_from_path(original_dirname) - - formatting_data = { - "original_dirname": original_dirname, - } - msg = "Path '{}' not in project folder.".format(original_dirname) + \ - " Please publish from inside of project folder." if not success: - raise PublishXmlValidationError(self, msg, key="not_in_dir", - formatting_data=formatting_data) + raise PublishXmlValidationError( + plugin=self, + message=( + "Path '{}' not in project folder. Please publish from " + "inside of project folder.".format(original_dirname) + ), + key="not_in_dir", + formatting_data={"original_dirname": original_dirname} + ) def _get_template_name_from_instance(self, instance): """Find template which will be used during integration.""" From 71bd10fffb7e3715108e0253203d28f32441998a Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 5 Sep 2023 08:32:22 +0100 Subject: [PATCH 294/429] Update openpype/hosts/maya/plugins/publish/extract_look.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index ec01a817b3..043d88db6d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -52,7 +52,7 @@ def find_paths_by_hash(texture_hash): """ if AYON_SERVER_ENABLED: - raise ValueError( + raise KnownPublishError( "This is a bug. \"find_paths_by_hash\" is not compatible with " "AYON." ) From 93a9ae2a6fa977b523de16b9f524e76d2d5fbf86 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 5 Sep 2023 08:33:49 +0100 Subject: [PATCH 295/429] Add TODO --- openpype/hosts/maya/plugins/publish/extract_look.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 043d88db6d..2708a6d916 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -591,8 +591,8 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary disable all hardlinking, due to the feature not being used - # or properly working. + # TODO: Temporary disable all hardlinking, due to the feature not being + # used or properly working. self.log.info( "Forcing copy instead of hardlink." ) From d54111bd75843c4713cac3409c1770594ce634e2 Mon Sep 17 00:00:00 2001 From: Mustafa Taher Date: Tue, 5 Sep 2023 11:59:12 +0300 Subject: [PATCH 296/429] Update docstring Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 3e51912c26..b108d0d881 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -654,8 +654,19 @@ def get_color_management_preferences(): def get_obj_node_output(obj_node): """Find output node. - get the output node with the minimum 'outputidx' - or the node with display flag. + If the node has any output node return the + output node with the minimum `outputidx`. + When no output is present return the node + with the display flag set. If no output node is + detected then None is returned. + + Arguments: + node (hou.Node): The node to retrieve a single + the output node for. + + Returns: + Optional[hou.Node]: The child output node. + """ outputs = obj_node.subnetOutputs() From d46f610e4d110a504991c9669fef644ec7e5c747 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 11:21:46 +0200 Subject: [PATCH 297/429] removed switch_ui script from fusion with related script in 'scripts' --- .../deploy/Scripts/Comp/OpenPype/switch_ui.py | 200 --------------- openpype/scripts/fusion_switch_shot.py | 241 ------------------ 2 files changed, 441 deletions(-) delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py delete mode 100644 openpype/scripts/fusion_switch_shot.py diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py deleted file mode 100644 index 87322235f5..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py +++ /dev/null @@ -1,200 +0,0 @@ -import os -import sys -import glob -import logging - -from qtpy import QtWidgets, QtCore - -import qtawesome as qta - -from openpype.client import get_assets -from openpype import style -from openpype.pipeline import ( - install_host, - get_current_project_name, -) -from openpype.hosts.fusion import api -from openpype.pipeline.context_tools import get_workdir_from_session - -log = logging.getLogger("Fusion Switch Shot") - - -class App(QtWidgets.QWidget): - - def __init__(self, parent=None): - - ################################################ - # |---------------------| |------------------| # - # |Comp | |Asset | # - # |[..][ v]| |[ v]| # - # |---------------------| |------------------| # - # | Update existing comp [ ] | # - # |------------------------------------------| # - # | Switch | # - # |------------------------------------------| # - ################################################ - - QtWidgets.QWidget.__init__(self, parent) - - layout = QtWidgets.QVBoxLayout() - - # Comp related input - comp_hlayout = QtWidgets.QHBoxLayout() - comp_label = QtWidgets.QLabel("Comp file") - comp_label.setFixedWidth(50) - comp_box = QtWidgets.QComboBox() - - button_icon = qta.icon("fa.folder", color="white") - open_from_dir = QtWidgets.QPushButton() - open_from_dir.setIcon(button_icon) - - comp_box.setFixedHeight(25) - open_from_dir.setFixedWidth(25) - open_from_dir.setFixedHeight(25) - - comp_hlayout.addWidget(comp_label) - comp_hlayout.addWidget(comp_box) - comp_hlayout.addWidget(open_from_dir) - - # Asset related input - asset_hlayout = QtWidgets.QHBoxLayout() - asset_label = QtWidgets.QLabel("Shot") - asset_label.setFixedWidth(50) - - asset_box = QtWidgets.QComboBox() - asset_box.setLineEdit(QtWidgets.QLineEdit()) - asset_box.setFixedHeight(25) - - refresh_icon = qta.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton() - refresh_btn.setIcon(refresh_icon) - - asset_box.setFixedHeight(25) - refresh_btn.setFixedWidth(25) - refresh_btn.setFixedHeight(25) - - asset_hlayout.addWidget(asset_label) - asset_hlayout.addWidget(asset_box) - asset_hlayout.addWidget(refresh_btn) - - # Options - options = QtWidgets.QHBoxLayout() - options.setAlignment(QtCore.Qt.AlignLeft) - - current_comp_check = QtWidgets.QCheckBox() - current_comp_check.setChecked(True) - current_comp_label = QtWidgets.QLabel("Use current comp") - - options.addWidget(current_comp_label) - options.addWidget(current_comp_check) - - accept_btn = QtWidgets.QPushButton("Switch") - - layout.addLayout(options) - layout.addLayout(comp_hlayout) - layout.addLayout(asset_hlayout) - layout.addWidget(accept_btn) - - self._open_from_dir = open_from_dir - self._comps = comp_box - self._assets = asset_box - self._use_current = current_comp_check - self._accept_btn = accept_btn - self._refresh_btn = refresh_btn - - self.setWindowTitle("Fusion Switch Shot") - self.setLayout(layout) - - self.resize(260, 140) - self.setMinimumWidth(260) - self.setFixedHeight(140) - - self.connections() - - # Update ui to correct state - self._on_use_current_comp() - self._refresh() - - def connections(self): - self._use_current.clicked.connect(self._on_use_current_comp) - self._open_from_dir.clicked.connect(self._on_open_from_dir) - self._refresh_btn.clicked.connect(self._refresh) - self._accept_btn.clicked.connect(self._on_switch) - - def _on_use_current_comp(self): - state = self._use_current.isChecked() - self._open_from_dir.setEnabled(not state) - self._comps.setEnabled(not state) - - def _on_open_from_dir(self): - - start_dir = get_workdir_from_session() - comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Choose comp", start_dir) - - if not comp_file: - return - - # Create completer - self.populate_comp_box([comp_file]) - self._refresh() - - def _refresh(self): - # Clear any existing items - self._assets.clear() - - asset_names = self.collect_asset_names() - completer = QtWidgets.QCompleter(asset_names) - - self._assets.setCompleter(completer) - self._assets.addItems(asset_names) - - def _on_switch(self): - - if not self._use_current.isChecked(): - file_name = self._comps.itemData(self._comps.currentIndex()) - else: - comp = api.get_current_comp() - file_name = comp.GetAttrs("COMPS_FileName") - - asset = self._assets.currentText() - - import colorbleed.scripts.fusion_switch_shot as switch_shot - switch_shot.switch(asset_name=asset, filepath=file_name, new=True) - - def collect_slap_comps(self, directory): - items = glob.glob("{}/*.comp".format(directory)) - return items - - def collect_asset_names(self): - project_name = get_current_project_name() - asset_docs = get_assets(project_name, fields=["name"]) - asset_names = { - asset_doc["name"] - for asset_doc in asset_docs - } - return list(asset_names) - - def populate_comp_box(self, files): - """Ensure we display the filename only but the path is stored as well - - Args: - files (list): list of full file path [path/to/item/item.ext,] - - Returns: - None - """ - - for f in files: - filename = os.path.basename(f) - self._comps.addItem(filename, userData=f) - - -if __name__ == '__main__': - install_host(api) - - app = QtWidgets.QApplication(sys.argv) - window = App() - window.setStyleSheet(style.load_stylesheet()) - window.show() - sys.exit(app.exec_()) diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py deleted file mode 100644 index 1cc728226f..0000000000 --- a/openpype/scripts/fusion_switch_shot.py +++ /dev/null @@ -1,241 +0,0 @@ -import os -import re -import sys -import logging - -from openpype.client import get_asset_by_name, get_versions - -# Pipeline imports -from openpype.hosts.fusion import api -import openpype.hosts.fusion.api.lib as fusion_lib - -# Config imports -from openpype.lib import version_up -from openpype.pipeline import ( - install_host, - registered_host, - legacy_io, - get_current_project_name, -) - -from openpype.pipeline.context_tools import get_workdir_from_session -from openpype.pipeline.version_start import get_versioning_start - -log = logging.getLogger("Update Slap Comp") - - -def _format_version_folder(folder): - """Format a version folder based on the filepath - - Args: - folder: file path to a folder - - Returns: - str: new version folder name - """ - - new_version = get_versioning_start( - get_current_project_name(), - "fusion", - family="workfile" - ) - if os.path.isdir(folder): - re_version = re.compile(r"v\d+$") - versions = [i for i in os.listdir(folder) if os.path.isdir(i) - and re_version.match(i)] - if versions: - # ensure the "v" is not included - new_version = int(max(versions)[1:]) + 1 - - version_folder = "v{:03d}".format(new_version) - - return version_folder - - -def _get_fusion_instance(): - fusion = getattr(sys.modules["__main__"], "fusion", None) - if fusion is None: - try: - # Support for FuScript.exe, BlackmagicFusion module for py2 only - import BlackmagicFusion as bmf - fusion = bmf.scriptapp("Fusion") - except ImportError: - raise RuntimeError("Could not find a Fusion instance") - return fusion - - -def _format_filepath(session): - - project = session["AVALON_PROJECT"] - asset = session["AVALON_ASSET"] - - # Save updated slap comp - work_path = get_workdir_from_session(session) - walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") - slapcomp_dir = os.path.abspath(walk_to_dir) - - # Ensure destination exists - if not os.path.isdir(slapcomp_dir): - log.warning("Folder did not exist, creating folder structure") - os.makedirs(slapcomp_dir) - - # Compute output path - new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset) - new_filepath = os.path.join(slapcomp_dir, new_filename) - - # Create new unqiue filepath - if os.path.exists(new_filepath): - new_filepath = version_up(new_filepath) - - return new_filepath - - -def _update_savers(comp, session): - """Update all savers of the current comp to ensure the output is correct - - Args: - comp (object): current comp instance - session (dict): the current Avalon session - - Returns: - None - """ - - new_work = get_workdir_from_session(session) - renders = os.path.join(new_work, "renders") - version_folder = _format_version_folder(renders) - renders_version = os.path.join(renders, version_folder) - - comp.Print("New renders to: %s\n" % renders) - - with api.comp_lock_and_undo_chunk(comp): - savers = comp.GetToolList(False, "Saver").values() - for saver in savers: - filepath = saver.GetAttrs("TOOLST_Clip_Name")[1.0] - filename = os.path.basename(filepath) - new_path = os.path.join(renders_version, filename) - saver["Clip"] = new_path - - -def update_frame_range(comp, representations): - """Update the frame range of the comp and render length - - The start and end frame are based on the lowest start frame and the highest - end frame - - Args: - comp (object): current focused comp - representations (list) collection of dicts - - Returns: - None - - """ - - version_ids = [r["parent"] for r in representations] - project_name = get_current_project_name() - versions = list(get_versions(project_name, version_ids=version_ids)) - - start = min(v["data"]["frameStart"] for v in versions) - end = max(v["data"]["frameEnd"] for v in versions) - - fusion_lib.update_frame_range(start, end, comp=comp) - - -def switch(asset_name, filepath=None, new=True): - """Switch the current containers of the file to the other asset (shot) - - Args: - filepath (str): file path of the comp file - asset_name (str): name of the asset (shot) - new (bool): Save updated comp under a different name - - Returns: - comp path (str): new filepath of the updated comp - - """ - - # If filepath provided, ensure it is valid absolute path - if filepath is not None: - if not os.path.isabs(filepath): - filepath = os.path.abspath(filepath) - - assert os.path.exists(filepath), "%s must exist " % filepath - - # Assert asset name exists - # It is better to do this here then to wait till switch_shot does it - project_name = get_current_project_name() - asset = get_asset_by_name(project_name, asset_name) - assert asset, "Could not find '%s' in the database" % asset_name - - # Go to comp - if not filepath: - current_comp = api.get_current_comp() - assert current_comp is not None, "Could not find current comp" - else: - fusion = _get_fusion_instance() - current_comp = fusion.LoadComp(filepath, quiet=True) - assert current_comp is not None, "Fusion could not load '%s'" % filepath - - host = registered_host() - containers = list(host.ls()) - assert containers, "Nothing to update" - - representations = [] - for container in containers: - try: - representation = fusion_lib.switch_item(container, - asset_name=asset_name) - representations.append(representation) - except Exception as e: - current_comp.Print("Error in switching! %s\n" % e.message) - - message = "Switched %i Loaders of the %i\n" % (len(representations), - len(containers)) - current_comp.Print(message) - - # Build the session to switch to - switch_to_session = legacy_io.Session.copy() - switch_to_session["AVALON_ASSET"] = asset['name'] - - if new: - comp_path = _format_filepath(switch_to_session) - - # Update savers output based on new session - _update_savers(current_comp, switch_to_session) - else: - comp_path = version_up(filepath) - - current_comp.Print(comp_path) - - current_comp.Print("\nUpdating frame range") - update_frame_range(current_comp, representations) - - current_comp.Save(comp_path) - - return comp_path - - -if __name__ == '__main__': - - import argparse - - parser = argparse.ArgumentParser(description="Switch to a shot within an" - "existing comp file") - - parser.add_argument("--file_path", - type=str, - default=True, - help="File path of the comp to use") - - parser.add_argument("--asset_name", - type=str, - default=True, - help="Name of the asset (shot) to switch") - - args, unknown = parser.parse_args() - - install_host(api) - switch(args.asset_name, args.file_path) - - sys.exit(0) From 2b8ec005fcc39f54e5a9f14a0352f98f6997034e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 11:27:31 +0200 Subject: [PATCH 298/429] removed remaining scripts after discussion with @BigRoy --- .../32bit/backgrounds_selected_to32bit.py | 16 -------- .../OpenPype/32bit/backgrounds_to32bit.py | 16 -------- .../32bit/loaders_selected_to32bit.py | 16 -------- .../Comp/OpenPype/32bit/loaders_to32bit.py | 16 -------- .../Comp/OpenPype/update_loader_ranges.py | 40 ------------------- 5 files changed, 104 deletions(-) delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py deleted file mode 100644 index 1a0a9911ea..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all selected backgrounds to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Selected Backgrounds to 32bit'): - tools = comp.GetToolList(True, "Background").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py deleted file mode 100644 index c2eea505e5..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all backgrounds to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Backgrounds to 32bit'): - tools = comp.GetToolList(False, "Background").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py deleted file mode 100644 index 2118767f4d..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all selected loaders to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Selected Loaders to 32bit'): - tools = comp.GetToolList(True, "Loader").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py deleted file mode 100644 index 7dd1f66a5e..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all loaders to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Loaders to 32bit'): - tools = comp.GetToolList(False, "Loader").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py deleted file mode 100644 index 3d2d1ecfa6..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Forces Fusion to 'retrigger' the Loader to update. - -Warning: - This might change settings like 'Reverse', 'Loop', trims and other - settings of the Loader. So use this at your own risk. - -""" -from openpype.hosts.fusion.api.pipeline import ( - get_current_comp, - comp_lock_and_undo_chunk -) - - -def update_loader_ranges(): - comp = get_current_comp() - with comp_lock_and_undo_chunk(comp, "Reload clip time ranges"): - tools = comp.GetToolList(True, "Loader").values() - for tool in tools: - - # Get tool attributes - tool_a = tool.GetAttrs() - clipTable = tool_a['TOOLST_Clip_Name'] - altclipTable = tool_a['TOOLST_AltClip_Name'] - startTime = tool_a['TOOLNT_Clip_Start'] - old_global_in = tool.GlobalIn[comp.CurrentTime] - - # Reapply - for index, _ in clipTable.items(): - time = startTime[index] - tool.Clip[time] = tool.Clip[time] - - for index, _ in altclipTable.items(): - time = startTime[index] - tool.ProxyFilename[time] = tool.ProxyFilename[time] - - tool.GlobalIn[comp.CurrentTime] = old_global_in - - -if __name__ == '__main__': - update_loader_ranges() From bde5a560a207dfb28839cb336b30fb59bb0762f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 11:28:25 +0200 Subject: [PATCH 299/429] Removed ';OpenPype:Scripts' from prefs file --- openpype/hosts/fusion/deploy/fusion_shared.prefs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/deploy/fusion_shared.prefs b/openpype/hosts/fusion/deploy/fusion_shared.prefs index b379ea7c66..93b08aa886 100644 --- a/openpype/hosts/fusion/deploy/fusion_shared.prefs +++ b/openpype/hosts/fusion/deploy/fusion_shared.prefs @@ -5,7 +5,7 @@ Global = { Map = { ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy", ["Config:"] = "UserPaths:Config;OpenPype:Config", - ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts", + ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts", }, }, Script = { From 564cb31cbe700aef687f6b11969eb530973e60d9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 12:05:40 +0200 Subject: [PATCH 300/429] Fix base `Extractor` being picked up as a plug-in to process --- openpype/plugins/publish/extract_review_slate.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 2ff7fbe7fa..d89fbb90c4 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -14,13 +14,11 @@ from openpype.lib import ( get_ffmpeg_codec_args, get_ffmpeg_format_args, ) -from openpype.pipeline.publish import ( - Extractor, - KnownPublishError -) +from openpype.pipeline import publish +from openpype.pipeline.publish import KnownPublishError -class ExtractReviewSlate(Extractor): +class ExtractReviewSlate(publish.Extractor): """ Will add slate frame at the start of the video files """ From e2c3a0f5be2be18ab71cfbd65632f66f1a8b218e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 17:33:25 +0200 Subject: [PATCH 301/429] better check of overriden '__init__' method --- openpype/pipeline/create/creator_plugins.py | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index de9cc7cff3..ab0d36e67e 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -214,7 +214,7 @@ class BaseCreator: # Backwards compatibility for system settings self.apply_settings(project_settings, system_settings) - init_overriden = self.__class__.__init__ is not BaseCreator.__init__ + init_overriden = self._method_is_overriden("__init__") if init_overriden or expect_system_settings: self.log.warning(( "WARNING: Source - Create plugin {}." @@ -225,6 +225,19 @@ class BaseCreator: " need to keep system settings." ).format(self.__class__.__name__)) + def _method_is_overriden(self, method_name): + """Check if method is overriden on objects class. + + Implemented for deprecation warning validation on init. + + Returns: + bool: True if method is overriden on objects class. + """ + + cls_method = getattr(BaseCreator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings. @@ -578,6 +591,11 @@ class Creator(BaseCreator): ) super(Creator, self).__init__(*args, **kwargs) + def _method_is_overriden(self, method_name): + cls_method = getattr(Creator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + @property def show_order(self): """Order in which is creator shown in UI. @@ -720,6 +738,11 @@ class HiddenCreator(BaseCreator): def create(self, instance_data, source_data): pass + def _method_is_overriden(self, method_name): + cls_method = getattr(HiddenCreator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + class AutoCreator(BaseCreator): """Creator which is automatically triggered without user interaction. @@ -731,6 +754,11 @@ class AutoCreator(BaseCreator): """Skip removement.""" pass + def _method_is_overriden(self, method_name): + cls_method = getattr(AutoCreator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + def discover_creator_plugins(*args, **kwargs): return discover(BaseCreator, *args, **kwargs) From c3847aec5186d26414f220369a89234f70740706 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 17:55:37 +0200 Subject: [PATCH 302/429] removed '_method_is_overriden' and use explicit class checks --- openpype/pipeline/create/creator_plugins.py | 40 ++++++--------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index ab0d36e67e..6aa08cae70 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -214,8 +214,16 @@ class BaseCreator: # Backwards compatibility for system settings self.apply_settings(project_settings, system_settings) - init_overriden = self._method_is_overriden("__init__") - if init_overriden or expect_system_settings: + init_use_base = any( + self.__class__.__init__ is cls.__init__ + for cls in { + BaseCreator, + Creator, + HiddenCreator, + AutoCreator, + } + ) + if not init_use_base or expect_system_settings: self.log.warning(( "WARNING: Source - Create plugin {}." " System settings argument will not be passed to" @@ -225,19 +233,6 @@ class BaseCreator: " need to keep system settings." ).format(self.__class__.__name__)) - def _method_is_overriden(self, method_name): - """Check if method is overriden on objects class. - - Implemented for deprecation warning validation on init. - - Returns: - bool: True if method is overriden on objects class. - """ - - cls_method = getattr(BaseCreator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings. @@ -591,11 +586,6 @@ class Creator(BaseCreator): ) super(Creator, self).__init__(*args, **kwargs) - def _method_is_overriden(self, method_name): - cls_method = getattr(Creator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - @property def show_order(self): """Order in which is creator shown in UI. @@ -738,11 +728,6 @@ class HiddenCreator(BaseCreator): def create(self, instance_data, source_data): pass - def _method_is_overriden(self, method_name): - cls_method = getattr(HiddenCreator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - class AutoCreator(BaseCreator): """Creator which is automatically triggered without user interaction. @@ -754,11 +739,6 @@ class AutoCreator(BaseCreator): """Skip removement.""" pass - def _method_is_overriden(self, method_name): - cls_method = getattr(AutoCreator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - def discover_creator_plugins(*args, **kwargs): return discover(BaseCreator, *args, **kwargs) From 8ceedd7b60c58bf94cc1721f033bff89c0a2b121 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 17:56:09 +0200 Subject: [PATCH 303/429] Draft for implementing a native Maya USD creator next to the Multiverse USD creator --- openpype/hosts/maya/api/plugin.py | 35 ++++ .../maya/plugins/create/create_maya_usd.py | 143 +++++++++++++++ .../plugins/create/create_multiverse_usd.py | 4 + .../hosts/maya/plugins/load/load_maya_usd.py | 100 +++++++++++ .../maya/plugins/publish/extract_maya_usd.py | 168 ++++++++++++++++++ .../plugins/publish/extract_multiverse_usd.py | 2 +- 6 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/maya/plugins/create/create_maya_usd.py create mode 100644 openpype/hosts/maya/plugins/load/load_maya_usd.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_maya_usd.py diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3f383fafb8..058637c8b5 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -129,12 +129,34 @@ class MayaCreatorBase(object): shared_data["maya_cached_legacy_subsets"] = cache_legacy return shared_data + def get_publish_families(self): + """Return families for the instances of this creator. + + Allow a Creator to define multiple families so that a creator can + e.g. specify `usd` and `usdMaya` and another USD creator can also + specify `usd` but apply different extractors like `usdMultiverse`. + + There is no need to override this method if you only have the + primary family defined by the `family` property as that will always + be set. + + Returns: + list: families for instances of this creator + + """ + return [] + def imprint_instance_node(self, node, data): # We never store the instance_node as value on the node since # it's the node name itself data.pop("instance_node", None) + # Don't store `families` since it's up to the creator itself + # to define the initial publish families - not a stored attribute of + # `families` + data.pop("families", None) + # We store creator attributes at the root level and assume they # will not clash in names with `subset`, `task`, etc. and other # default names. This is just so these attributes in many cases @@ -186,6 +208,11 @@ class MayaCreatorBase(object): # Explicitly re-parse the node name node_data["instance_node"] = node + # If the creator plug-in specifies + families = self.get_publish_families() + if families: + node_data["families"] = families + return node_data def _default_collect_instances(self): @@ -230,6 +257,14 @@ class MayaCreator(NewCreator, MayaCreatorBase): if pre_create_data.get("use_selection"): members = cmds.ls(selection=True) + # Allow a Creator to define multiple families + publish_families = self.get_publish_families() + if publish_families: + families = instance_data.setdefault("families", []) + for family in self.get_publish_families(): + if family not in families: + families.append(family) + with lib.undo_chunk(): instance_node = cmds.sets(members, name=subset_name) instance_data["instance_node"] = instance_node diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py new file mode 100644 index 0000000000..298dc6a24f --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -0,0 +1,143 @@ +from openpype.hosts.maya.api import plugin, lib +from openpype.lib import ( + BoolDef, + NumberDef, + TextDef, + EnumDef +) + + +class CreateMayaUsd(plugin.MayaCreator): + """Create Maya USD Export""" + + identifier = "io.openpype.creators.maya.mayausd" + label = "Maya USD" + family = "usd" + icon = "cubes" + description = "Create Maya USD Export" + + def get_publish_families(self): + return ["usd", "mayaUsd"] + + def get_instance_attr_defs(self): + + defs = lib.collect_animation_defs() + defs.extend([ + EnumDef("defaultUSDFormat", + label="File format", + items={ + "usdc": "Binary", + "usda": "ASCII" + }, + default="usdc"), + BoolDef("stripNamespaces", + label="Strip Namespaces", + default=True), + BoolDef("mergeTransformAndShape", + label="Merge Transform and Shape", + default=True), + # BoolDef("writeAncestors", + # label="Write Ancestors", + # default=True), + # BoolDef("flattenParentXforms", + # label="Flatten Parent Xforms", + # default=False), + # BoolDef("writeSparseOverrides", + # label="Write Sparse Overrides", + # default=False), + # BoolDef("useMetaPrimPath", + # label="Use Meta Prim Path", + # default=False), + # TextDef("customRootPath", + # label="Custom Root Path", + # default=''), + # TextDef("customAttributes", + # label="Custom Attributes", + # tooltip="Comma-separated list of attribute names", + # default=''), + # TextDef("nodeTypesToIgnore", + # label="Node Types to Ignore", + # tooltip="Comma-separated list of node types to be ignored", + # default=''), + # BoolDef("writeMeshes", + # label="Write Meshes", + # default=True), + # BoolDef("writeCurves", + # label="Write Curves", + # default=True), + # BoolDef("writeParticles", + # label="Write Particles", + # default=True), + # BoolDef("writeCameras", + # label="Write Cameras", + # default=False), + # BoolDef("writeLights", + # label="Write Lights", + # default=False), + # BoolDef("writeJoints", + # label="Write Joints", + # default=False), + # BoolDef("writeCollections", + # label="Write Collections", + # default=False), + # BoolDef("writePositions", + # label="Write Positions", + # default=True), + # BoolDef("writeNormals", + # label="Write Normals", + # default=True), + # BoolDef("writeUVs", + # label="Write UVs", + # default=True), + # BoolDef("writeColorSets", + # label="Write Color Sets", + # default=False), + # BoolDef("writeTangents", + # label="Write Tangents", + # default=False), + # BoolDef("writeRefPositions", + # label="Write Ref Positions", + # default=True), + # BoolDef("writeBlendShapes", + # label="Write BlendShapes", + # default=False), + # BoolDef("writeDisplayColor", + # label="Write Display Color", + # default=True), + # BoolDef("writeSkinWeights", + # label="Write Skin Weights", + # default=False), + # BoolDef("writeMaterialAssignment", + # label="Write Material Assignment", + # default=False), + # BoolDef("writeHardwareShader", + # label="Write Hardware Shader", + # default=False), + # BoolDef("writeShadingNetworks", + # label="Write Shading Networks", + # default=False), + # BoolDef("writeTransformMatrix", + # label="Write Transform Matrix", + # default=True), + # BoolDef("writeUsdAttributes", + # label="Write USD Attributes", + # default=True), + # BoolDef("writeInstancesAsReferences", + # label="Write Instances as References", + # default=False), + # BoolDef("timeVaryingTopology", + # label="Time Varying Topology", + # default=False), + # TextDef("customMaterialNamespace", + # label="Custom Material Namespace", + # default=''), + # NumberDef("numTimeSamples", + # label="Num Time Samples", + # default=1), + # NumberDef("timeSamplesSpan", + # label="Time Samples Span", + # default=0.0), + # + ]) + + return defs diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index 0b0ad3bccb..2963d4d5b6 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -14,6 +14,10 @@ class CreateMultiverseUsd(plugin.MayaCreator): label = "Multiverse USD Asset" family = "usd" icon = "cubes" + description = "Create Multiverse USD Asset" + + def get_publish_families(self): + return ["usd", "mvUsd"] def get_instance_attr_defs(self): diff --git a/openpype/hosts/maya/plugins/load/load_maya_usd.py b/openpype/hosts/maya/plugins/load/load_maya_usd.py new file mode 100644 index 0000000000..26c497768d --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_maya_usd.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +import maya.cmds as cmds + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.pipeline.load import get_representation_path_from_context +from openpype.hosts.maya.api.lib import ( + namespaced, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise + + +class MayaUsdLoader(load.LoaderPlugin): + """Read USD data in a Maya USD Proxy""" + + families = ["model", "usd", "pointcache", "animation"] + representations = ["usd", "usda", "usdc", "usdz", "abc"] + + label = "Load USD to Maya Proxy" + order = -1 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, options=None): + asset = context['asset']['name'] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + # Make sure we can load the plugin + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + path = get_representation_path_from_context(context) + + # Create the shape + cmds.namespace(addNamespace=namespace) + with namespaced(namespace, new=False): + transform = cmds.createNode("transform", + name=name, + skipSelect=True) + proxy = cmds.createNode('mayaUsdProxyShape', + name="{}Shape".format(name), + parent=transform, + skipSelect=True) + + cmds.connectAttr("time1.outTime", "{}.time".format(proxy)) + cmds.setAttr("{}.filePath".format(proxy), path, type="string") + + nodes = [transform, proxy] + self[:] = nodes + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__) + + def update(self, container, representation): + # type: (dict, dict) -> None + """Update container with specified representation.""" + node = container['objectName'] + assert cmds.objExists(node), "Missing container" + + members = cmds.sets(node, query=True) or [] + shapes = cmds.ls(members, type="mayaUsdProxyShape") + + path = get_representation_path(representation) + for shape in shapes: + cmds.setAttr("{}.filePath".format(shape), path, type="string") + + cmds.setAttr("{}.representation".format(node), + str(representation["_id"]), + type="string") + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + # type: (dict) -> None + """Remove loaded container.""" + # Delete container and its contents + if cmds.objExists(container['objectName']): + members = cmds.sets(container['objectName'], query=True) or [] + cmds.delete([container['objectName']] + members) + + # Remove the namespace, if empty + namespace = container['namespace'] + if cmds.namespace(exists=namespace): + members = cmds.namespaceInfo(namespace, listNamespace=True) + if not members: + cmds.namespace(removeNamespace=namespace) + else: + self.log.warning("Namespace not deleted because it " + "still has members: %s", namespace) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py new file mode 100644 index 0000000000..3b95037d4c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -0,0 +1,168 @@ +import os +import six + +from maya import cmds + +import pyblish.api +from openpype.pipeline import publish +from openpype.hosts.maya.api.lib import maintained_selection + + +class ExtractMayaUsd(publish.Extractor): + """Extractor for Maya USD Asset data. + + Upon publish a .usd (or .usdz) asset file will typically be written. + """ + + label = "Extract Maya USD Asset" + hosts = ["maya"] + families = ["mayaUsd"] + + @property + def options(self): + """Overridable options for Maya USD Export + + Given in the following format + - {NAME: EXPECTED TYPE} + + If the overridden option's type does not match, + the option is not included and a warning is logged. + + """ + + # TODO: Support more `mayaUSDExport` parameters + return { + "stripNamespaces": bool, + "mergeTransformAndShape": bool, + "exportDisplayColor": bool, + "exportColorSets": bool, + "exportInstances": bool, + "exportUVs": bool, + "exportVisibility": bool, + "exportComponentTags": bool, + "exportRefsAsInstanceable": bool, + "eulerFilter": bool, + "renderableOnly": bool, + #"worldspace": bool, + } + + @property + def default_options(self): + """The default options for Maya USD Export.""" + + # TODO: Support more `mayaUSDExport` parameters + return { + "stripNamespaces": False, + "mergeTransformAndShape": False, + "exportDisplayColor": False, + "exportColorSets": True, + "exportInstances": True, + "exportUVs": True, + "exportVisibility": True, + "exportComponentTags": True, + "exportRefsAsInstanceable": False, + "eulerFilter": True, + "renderableOnly": False, + #"worldspace": False + } + + def parse_overrides(self, instance, options): + """Inspect data of instance to determine overridden options""" + + for key in instance.data: + if key not in self.options: + continue + + # Ensure the data is of correct type + value = instance.data[key] + if isinstance(value, six.text_type): + value = str(value) + if not isinstance(value, self.options[key]): + self.log.warning( + "Overridden attribute {key} was of " + "the wrong type: {invalid_type} " + "- should have been {valid_type}".format( + key=key, + invalid_type=type(value).__name__, + valid_type=self.options[key].__name__)) + continue + + options[key] = value + + return options + + def filter_members(self, members): + # Can be overridden by inherited classes + return members + + def process(self, instance): + + # Load plugin first + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + # Define output file path + staging_dir = self.staging_dir(instance) + file_name = "{0}.usd".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + file_path = file_path.replace('\\', '/') + + # Parse export options + options = self.default_options + options = self.parse_overrides(instance, options) + self.log.info("Export options: {0}".format(options)) + + # Perform extraction + self.log.debug("Performing extraction ...") + + members = instance.data("setMembers") + self.log.debug('Collected objects: {}'.format(members)) + members = self.filter_members(members) + if not members: + self.log.error('No members!') + return + + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + + with maintained_selection(): + self.log.debug('Exporting USD: {} / {}'.format(file_path, members)) + cmds.mayaUSDExport(file=file_path, + frameRange=(start, end), + frameStride=instance.data.get("step", 1.0), + exportRoots=members, + **options) + + representation = { + 'name': "usd", + 'ext': "usd", + 'files': file_name, + 'stagingDir': staging_dir + } + instance.data.setdefault("representations", []).append(representation) + + self.log.debug( + "Extracted instance {} to {}".format(instance.name, file_path) + ) + + +class ExtractMayaUsdAnim(ExtractMayaUsd): + """Extractor for Maya USD Animation Sparse Cache data. + + This will extract the sparse cache data from the scene and generate a + USD file with all the animation data. + + Upon publish a .usd sparse cache will be written. + """ + label = "Extract Maya USD Animation Sparse Cache" + families = ["animation", "mayaUsd"] + match = pyblish.api.Subset + + def filter_members(self, members): + out_set = next((i for i in members if i.endswith("out_SET")), None) + + if out_set is None: + self.log.warning("Expecting out_SET") + return None + + members = cmds.ls(cmds.sets(out_set, query=True), long=True) + return members diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 4399eacda1..e0a1369556 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -28,7 +28,7 @@ class ExtractMultiverseUsd(publish.Extractor): label = "Extract Multiverse USD Asset" hosts = ["maya"] - families = ["usd"] + families = ["mvUsd"] scene_type = "usd" file_formats = ["usd", "usda", "usdz"] From 90417a42c38e6197583969d2549de2b3b810568d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 17:56:30 +0200 Subject: [PATCH 304/429] Allow loading USD into Arnold Standin in Maya --- openpype/hosts/maya/plugins/load/load_arnold_standin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py index b5cc4d629b..e1bd1954fa 100644 --- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -32,8 +32,8 @@ def get_current_session_fps(): class ArnoldStandinLoader(load.LoaderPlugin): """Load as Arnold standin""" - families = ["ass", "animation", "model", "proxyAbc", "pointcache"] - representations = ["ass", "abc"] + families = ["ass", "animation", "model", "proxyAbc", "pointcache", "usd"] + representations = ["ass", "abc", "usda", "usdc", "usd"] label = "Load as Arnold standin" order = -5 From 4456ac86f41e7fa529b5f07d32da6e71f601128d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 5 Sep 2023 17:56:49 +0200 Subject: [PATCH 305/429] :art: add default isort config --- setup.cfg | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 10cca3eb3f..216bae848f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,4 +28,11 @@ omit = /tests directory = ./coverage [tool:pytest] -norecursedirs = repos/* openpype/modules/ftrack/* \ No newline at end of file +norecursedirs = repos/* openpype/modules/ftrack/* + +[isort] +line_length = 79 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +combine_as_imports = True From 34b15587c11005ca9c2d7db77f44fa18ebb37f4e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 18:00:23 +0200 Subject: [PATCH 306/429] Cosmetics --- openpype/hosts/maya/plugins/load/load_arnold_standin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py index e1bd1954fa..2e1329f201 100644 --- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -17,6 +17,7 @@ from openpype.hosts.maya.api.lib import ( ) from openpype.hosts.maya.api.pipeline import containerise + def is_sequence(files): sequence = False collections, remainder = clique.assemble(files, minimum_items=1) @@ -29,6 +30,7 @@ def get_current_session_fps(): session_fps = float(legacy_io.Session.get('AVALON_FPS', 25)) return convert_to_maya_fps(session_fps) + class ArnoldStandinLoader(load.LoaderPlugin): """Load as Arnold standin""" From 5c12d9c8621130703d063ec5aba0fef832d479ac Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 18:04:21 +0200 Subject: [PATCH 307/429] Remove commented out attribute definitions --- .../maya/plugins/create/create_maya_usd.py | 106 +----------------- 1 file changed, 1 insertion(+), 105 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py index 298dc6a24f..554b8169db 100644 --- a/openpype/hosts/maya/plugins/create/create_maya_usd.py +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -1,8 +1,6 @@ from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, - NumberDef, - TextDef, EnumDef ) @@ -35,109 +33,7 @@ class CreateMayaUsd(plugin.MayaCreator): default=True), BoolDef("mergeTransformAndShape", label="Merge Transform and Shape", - default=True), - # BoolDef("writeAncestors", - # label="Write Ancestors", - # default=True), - # BoolDef("flattenParentXforms", - # label="Flatten Parent Xforms", - # default=False), - # BoolDef("writeSparseOverrides", - # label="Write Sparse Overrides", - # default=False), - # BoolDef("useMetaPrimPath", - # label="Use Meta Prim Path", - # default=False), - # TextDef("customRootPath", - # label="Custom Root Path", - # default=''), - # TextDef("customAttributes", - # label="Custom Attributes", - # tooltip="Comma-separated list of attribute names", - # default=''), - # TextDef("nodeTypesToIgnore", - # label="Node Types to Ignore", - # tooltip="Comma-separated list of node types to be ignored", - # default=''), - # BoolDef("writeMeshes", - # label="Write Meshes", - # default=True), - # BoolDef("writeCurves", - # label="Write Curves", - # default=True), - # BoolDef("writeParticles", - # label="Write Particles", - # default=True), - # BoolDef("writeCameras", - # label="Write Cameras", - # default=False), - # BoolDef("writeLights", - # label="Write Lights", - # default=False), - # BoolDef("writeJoints", - # label="Write Joints", - # default=False), - # BoolDef("writeCollections", - # label="Write Collections", - # default=False), - # BoolDef("writePositions", - # label="Write Positions", - # default=True), - # BoolDef("writeNormals", - # label="Write Normals", - # default=True), - # BoolDef("writeUVs", - # label="Write UVs", - # default=True), - # BoolDef("writeColorSets", - # label="Write Color Sets", - # default=False), - # BoolDef("writeTangents", - # label="Write Tangents", - # default=False), - # BoolDef("writeRefPositions", - # label="Write Ref Positions", - # default=True), - # BoolDef("writeBlendShapes", - # label="Write BlendShapes", - # default=False), - # BoolDef("writeDisplayColor", - # label="Write Display Color", - # default=True), - # BoolDef("writeSkinWeights", - # label="Write Skin Weights", - # default=False), - # BoolDef("writeMaterialAssignment", - # label="Write Material Assignment", - # default=False), - # BoolDef("writeHardwareShader", - # label="Write Hardware Shader", - # default=False), - # BoolDef("writeShadingNetworks", - # label="Write Shading Networks", - # default=False), - # BoolDef("writeTransformMatrix", - # label="Write Transform Matrix", - # default=True), - # BoolDef("writeUsdAttributes", - # label="Write USD Attributes", - # default=True), - # BoolDef("writeInstancesAsReferences", - # label="Write Instances as References", - # default=False), - # BoolDef("timeVaryingTopology", - # label="Time Varying Topology", - # default=False), - # TextDef("customMaterialNamespace", - # label="Custom Material Namespace", - # default=''), - # NumberDef("numTimeSamples", - # label="Num Time Samples", - # default=1), - # NumberDef("timeSamplesSpan", - # label="Time Samples Span", - # default=0.0), - # + default=True) ]) return defs From d6f2ace99d87d2cb8069c02de72c6f7244a222bf Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 18:04:57 +0200 Subject: [PATCH 308/429] Cosmetics --- openpype/hosts/maya/plugins/publish/extract_maya_usd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py index 3b95037d4c..70508042c0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -43,7 +43,7 @@ class ExtractMayaUsd(publish.Extractor): "exportRefsAsInstanceable": bool, "eulerFilter": bool, "renderableOnly": bool, - #"worldspace": bool, + # "worldspace": bool, } @property @@ -63,7 +63,7 @@ class ExtractMayaUsd(publish.Extractor): "exportRefsAsInstanceable": False, "eulerFilter": True, "renderableOnly": False, - #"worldspace": False + # "worldspace": False } def parse_overrides(self, instance, options): From 1b7e1aeed7c09014855f5d16bef37f22f917f3f0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 18:12:49 +0200 Subject: [PATCH 309/429] Fix attribute definitions for `CreateYetiCache` --- openpype/hosts/maya/plugins/create/create_yeti_cache.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_yeti_cache.py b/openpype/hosts/maya/plugins/create/create_yeti_cache.py index 395aa62325..ca002392d4 100644 --- a/openpype/hosts/maya/plugins/create/create_yeti_cache.py +++ b/openpype/hosts/maya/plugins/create/create_yeti_cache.py @@ -13,8 +13,7 @@ class CreateYetiCache(plugin.MayaCreator): family = "yeticache" icon = "pagelines" - def __init__(self, *args, **kwargs): - super(CreateYetiCache, self).__init__(*args, **kwargs) + def get_instance_attr_defs(self): defs = [ NumberDef("preroll", @@ -36,3 +35,5 @@ class CreateYetiCache(plugin.MayaCreator): default=3, decimals=0) ) + + return defs From 45f86749e165f3825ccdff4dbe8200180f4369b8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 5 Sep 2023 19:52:13 +0300 Subject: [PATCH 310/429] resolve some comments --- .../create/create_unreal_staticmesh.py | 50 +++------- .../hosts/houdini/plugins/load/load_fbx.py | 2 +- .../houdini/plugins/publish/extract_fbx.py | 45 +++------ ...ut_node.py => validate_fbx_output_node.py} | 3 +- .../publish/validate_mesh_is_static.py | 2 +- .../plugins/publish/validate_subset_name.py | 94 +++++++++++++++++++ .../validate_unreal_staticmesh_naming.py | 21 +---- .../defaults/project_settings/houdini.json | 10 ++ .../schemas/schema_houdini_publish.json | 8 ++ .../server/settings/publish_plugins.py | 16 ++++ 10 files changed, 164 insertions(+), 87 deletions(-) rename openpype/hosts/houdini/plugins/publish/{validate_output_node.py => validate_fbx_output_node.py} (93%) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_subset_name.py diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index ca5e2e8fb4..fc3783c0d1 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -28,8 +28,18 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): # get the created rop node instance_node = hou.node(instance.get("instance_node")) - # get parms - parms = self.get_parms(subset_name, pre_create_data) + # prepare parms + output_path = hou.text.expandString("$HIP/pyblish/{}.fbx".format(subset_name)) + parms = { + "startnode": self.get_selection(), + "sopoutput": output_path, + # vertex cache format + "vcformat": pre_create_data.get("vcformat"), + "convertunits": pre_create_data.get("convertunits"), + # set render range to use frame range start-end frame + "trange": 1, + "createsubnetroot": pre_create_data.get("createsubnetroot") + } # set parms instance_node.setParms(parms) @@ -47,7 +57,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): """Add settings for users. """ - attrs = super().get_pre_create_attr_defs() + attrs = super(CreateUnrealStaticMesh, self).get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " "Export node when it's a " @@ -86,40 +96,6 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): dynamic_data["asset"] = asset_doc["name"] return dynamic_data - def get_parms(self, subset_name, pre_create_data): - """Get parameters values. """ - - # 1. get output path - output_path = hou.text.expandString( - "$HIP/pyblish/{}.fbx".format(subset_name)) - - # 2. get selection - selection = self.get_selection() - - # 3. get Vertex Cache Format - vcformat = pre_create_data.get("vcformat") - - # 4. get convert_units - convertunits = pre_create_data.get("convertunits") - - # 5. get Valid Frame Range - trange = 1 - - # 6. get createsubnetroot - createsubnetroot = pre_create_data.get("createsubnetroot") - - # parms dictionary - parms = { - "startnode": selection, - "sopoutput": output_path, - "vcformat": vcformat, - "convertunits": convertunits, - "trange": trange, - "createsubnetroot": createsubnetroot - } - - return parms - def get_selection(self): """Selection Logic. diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 9c7dbf578e..7e7f0c04e5 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -146,5 +146,5 @@ class FbxLoader(load.LoaderPlugin): # Set new position for children nodes parent_node.layoutChildren() - # Retrun all the nodes + # Return all the nodes return [parent_node, file_node, attribdelete, null] diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 2a95734ece..e8cd207818 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -21,40 +21,17 @@ class ExtractFBX(publish.Extractor): # get rop node ropnode = hou.node(instance.data.get("instance_node")) + output_node = ropnode.evalParm("sopoutput") + + # get staging_dir and file_name + staging_dir = os.path.normpath(os.path.dirname(output_node)) + file_name = os.path.basename(output_node) # render rop + self.log.debug("Writing FBX '%s' to '%s'",file_name, staging_dir) render_rop(ropnode) - # get required data - file_name, staging_dir = self.get_paths_data(ropnode) - representation = self.get_representation(instance, - file_name, - staging_dir) - - # set value type for 'representations' key to list - if "representations" not in instance.data: - instance.data["representations"] = [] - - # update instance data - instance.data["stagingDir"] = staging_dir - instance.data["representations"].append(representation) - - def get_paths_data(self, ropnode): - # Get the filename from the filename parameter - output = ropnode.evalParm("sopoutput") - - staging_dir = os.path.normpath(os.path.dirname(output)) - - file_name = os.path.basename(output) - - self.log.info("Writing FBX '%s' to '%s'" % (file_name, - staging_dir)) - - return file_name, staging_dir - - def get_representation(self, instance, - file_name, staging_dir): - + # prepare representation representation = { "name": "fbx", "ext": "fbx", @@ -67,4 +44,10 @@ class ExtractFBX(publish.Extractor): representation["frameStart"] = instance.data["frameStart"] representation["frameEnd"] = instance.data["frameEnd"] - return representation + # set value type for 'representations' key to list + if "representations" not in instance.data: + instance.data["representations"] = [] + + # update instance data + instance.data["stagingDir"] = staging_dir + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py similarity index 93% rename from openpype/hosts/houdini/plugins/publish/validate_output_node.py rename to openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index 99a6cda077..503a3bb3c1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -9,12 +9,13 @@ from openpype.hosts.houdini.api.action import ( import hou -class ValidateOutputNode(pyblish.api.InstancePlugin): +class ValidateFBXOutputNode(pyblish.api.InstancePlugin): """Validate the instance Output Node. This will ensure: - The Output Node Path is set. - The Output Node Path refers to an existing object. + - The Output Node is a Sop or Obj node. """ order = pyblish.api.ValidatorOrder diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 25ab362a88..4d0904eb53 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -17,7 +17,7 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate mesh is static. - It checks if output node is time dependant. + It checks if output node is time dependent. """ families = ["staticMesh"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py new file mode 100644 index 0000000000..299729a6e8 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.pipeline.create import get_subset_name + +import hou + + +class FixSubsetNameAction(RepairAction): + label = "Fix Subset Name" + + +class ValidateSubsetName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate Subset name. + + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate Subset Name" + order = ValidateContentsOrder + 0.1 + actions = [FixSubsetNameAction, SelectInvalidAction] + + optional = True + + + def process(self, instance): + + if not self.is_active(instance.data): + return + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + rop_node = hou.node(instance.data["instance_node"]) + + # Check subset name + subset_name = get_subset_name( + family=instance.data["family"], + variant=instance.data["variant"], + task_name=instance.data["task"], + asset_doc=instance.data["assetEntity"], + dynamic_data={"asset":instance.data["asset"]} + ) + + if instance.data.get("subset") != subset_name: + invalid.append(rop_node) + cls.log.error( + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name + ) + + return invalid + + @classmethod + def repair(cls, instance): + rop_node = hou.node(instance.data["instance_node"]) + + # Check subset name + subset_name = get_subset_name( + family=instance.data["family"], + variant=instance.data["variant"], + task_name=instance.data["task"], + asset_doc=instance.data["assetEntity"], + dynamic_data={"asset":instance.data["asset"]} + ) + + instance.data["subset"] = subset_name + rop_node.parm("subset").set(subset_name) + + cls.log.debug( + "Subset name on rop node '%s' has been set to '%s'.", + rop_node.path(), subset_name + ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 5558b43258..791db8198f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -35,9 +35,12 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, actions = [SelectInvalidAction] optional = True + collision_prefixes = [] + static_mesh_prefix = "" @classmethod def apply_settings(cls, project_settings, system_settings): + settings = ( project_settings["houdini"]["create"]["CreateUnrealStaticMesh"] ) @@ -51,7 +54,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid = self.get_invalid(instance) if invalid: - nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) @@ -70,7 +73,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return - if not rop_node.evalParm('buildfrompath'): + if rop_node.evalParm("buildfrompath"): # This validator doesn't support naming check if # building hierarchy from path' is used cls.log.info( @@ -91,18 +94,4 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) break - # Check subset name - subset_name = "{}_{}{}".format( - cls.static_mesh_prefix, - instance.data["asset"], - instance.data.get("variant", "") - ) - - if instance.data.get("subset") != subset_name: - invalid.append(rop_node) - cls.log.error( - "Invalid subset name on rop node '%s' should be '%s'.", - rop_node.path(), subset_name - ) - return invalid diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 65f13fa1ab..7673725831 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -111,6 +111,16 @@ "optional": true, "active": true }, + "ValidateSubsetName": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateMeshIsStatic": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateUnrealStaticMeshName": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index 4339f86db6..670b1a0bc2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -44,6 +44,14 @@ "key": "ValidateContainers", "label": "ValidateContainers" }, + { + "key": "ValidateSubsetName", + "label": "Validate Subset Name" + }, + { + "key": "ValidateMeshIsStatic", + "label": "Validate Mesh is Static" + }, { "key": "ValidateUnrealStaticMeshName", "label": "Validate Unreal Static Mesh Name" diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 335751e5f9..b3e47d6948 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -164,6 +164,12 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Latest Containers.") + ValidateSubsetName: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Subset Name.") + ValidateMeshIsStatic: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Mesh is Static.") ValidateUnrealStaticMeshName: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Unreal Static Mesh Name.") @@ -187,6 +193,16 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateSubsetName": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateMeshIsStatic": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateUnrealStaticMeshName": { "enabled": True, "optional": True, From ebae3cf03ef443b51296eb160a06d26e3c4ba637 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 5 Sep 2023 19:54:31 +0300 Subject: [PATCH 311/429] remove white spaces --- openpype/hosts/houdini/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index b108d0d881..7d3edbc707 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -654,19 +654,19 @@ def get_color_management_preferences(): def get_obj_node_output(obj_node): """Find output node. - If the node has any output node return the + If the node has any output node return the output node with the minimum `outputidx`. When no output is present return the node with the display flag set. If no output node is detected then None is returned. - + Arguments: node (hou.Node): The node to retrieve a single the output node for. - + Returns: Optional[hou.Node]: The child output node. - + """ outputs = obj_node.subnetOutputs() From f00d76c0330f37eff8956cfd633484e1cd607ec5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 5 Sep 2023 19:58:13 +0300 Subject: [PATCH 312/429] resolve hound --- .../hosts/houdini/plugins/create/create_unreal_staticmesh.py | 5 ++++- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 4 ++-- .../hosts/houdini/plugins/publish/validate_subset_name.py | 5 ++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index fc3783c0d1..2f92def54a 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -29,7 +29,10 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): instance_node = hou.node(instance.get("instance_node")) # prepare parms - output_path = hou.text.expandString("$HIP/pyblish/{}.fbx".format(subset_name)) + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name) + ) + parms = { "startnode": self.get_selection(), "sopoutput": output_path, diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index e8cd207818..dd61e68f3b 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -28,7 +28,7 @@ class ExtractFBX(publish.Extractor): file_name = os.path.basename(output_node) # render rop - self.log.debug("Writing FBX '%s' to '%s'",file_name, staging_dir) + self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) render_rop(ropnode) # prepare representation @@ -36,7 +36,7 @@ class ExtractFBX(publish.Extractor): "name": "fbx", "ext": "fbx", "files": file_name, - "stagingDir": staging_dir, + "stagingDir": staging_dir } # A single frame may also be rendered without start/end frame. diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py index 299729a6e8..bb3648f361 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py +++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py @@ -33,7 +33,6 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, optional = True - def process(self, instance): if not self.is_active(instance.data): @@ -60,7 +59,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, variant=instance.data["variant"], task_name=instance.data["task"], asset_doc=instance.data["assetEntity"], - dynamic_data={"asset":instance.data["asset"]} + dynamic_data={"asset": instance.data["asset"]} ) if instance.data.get("subset") != subset_name: @@ -82,7 +81,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, variant=instance.data["variant"], task_name=instance.data["task"], asset_doc=instance.data["assetEntity"], - dynamic_data={"asset":instance.data["asset"]} + dynamic_data={"asset": instance.data["asset"]} ) instance.data["subset"] = subset_name From 7f78a95559870a79f2b20c456ea4ec8a3419e30d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 22:04:38 +0200 Subject: [PATCH 313/429] Export correct file type (ascii vs binary) based on instance setting --- openpype/hosts/maya/plugins/publish/extract_maya_usd.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py index 70508042c0..32730d2963 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -32,6 +32,7 @@ class ExtractMayaUsd(publish.Extractor): # TODO: Support more `mayaUSDExport` parameters return { + "defaultUSDFormat": str, "stripNamespaces": bool, "mergeTransformAndShape": bool, "exportDisplayColor": bool, @@ -52,6 +53,7 @@ class ExtractMayaUsd(publish.Extractor): # TODO: Support more `mayaUSDExport` parameters return { + "defaultUSDFormat": "usdc", "stripNamespaces": False, "mergeTransformAndShape": False, "exportDisplayColor": False, From 93e3e310295c758f236e208dc7ed130eee4a4335 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 22:05:02 +0200 Subject: [PATCH 314/429] Log message as debug --- openpype/hosts/maya/plugins/publish/extract_maya_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py index 32730d2963..4a5fcc3366 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -111,7 +111,7 @@ class ExtractMayaUsd(publish.Extractor): # Parse export options options = self.default_options options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction self.log.debug("Performing extraction ...") From f4a0ab45e4a9f50d60488fb8cfc7ccaae7f016a0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 23:10:32 +0200 Subject: [PATCH 315/429] Allow exporting custom attributes with `mayaUSDExport` --- .../maya/plugins/create/create_maya_usd.py | 16 ++- .../maya/plugins/publish/extract_maya_usd.py | 113 +++++++++++++++++- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py index 554b8169db..f05f155dd9 100644 --- a/openpype/hosts/maya/plugins/create/create_maya_usd.py +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -1,7 +1,8 @@ from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, - EnumDef + EnumDef, + TextDef ) @@ -33,7 +34,18 @@ class CreateMayaUsd(plugin.MayaCreator): default=True), BoolDef("mergeTransformAndShape", label="Merge Transform and Shape", - default=True) + default=True), + BoolDef("includeUserDefinedAttributes", + label="Include User Defined Attributes", + default=False), + TextDef("attr", + label="Custom Attributes", + default="", + placeholder="attr1, attr2"), + TextDef("attrPrefix", + label="Custom Attributes Prefix", + default="", + placeholder="prefix1, prefix2") ]) return defs diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py index 4a5fcc3366..dfdea6868f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -1,5 +1,7 @@ import os import six +import json +import contextlib from maya import cmds @@ -8,6 +10,88 @@ from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection +@contextlib.contextmanager +def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): + """Define attributes for the given nodes that should be exported. + + MayaUSDExport will export custom attributes if the Maya node has a + string attribute `USD_UserExportedAttributesJson` that provides an + export mapping for the maya attributes. This context manager will try + to autogenerate such an attribute during the export to include attributes + for the export. + + """ + # todo: this might be better done with a custom export chaser + # see `chaser` argument for `mayaUSDExport` + + import maya.api.OpenMaya as om + + if not attrs and not attr_prefixes: + # context manager does nothing + yield + return + + if attrs is None: + attrs = [] + if attr_prefixes is None: + attr_prefixes = [] + if mapping is None: + mapping = {} + + usd_json_attr = "USD_UserExportedAttributesJson" + strings = attrs + ["{}*".format(prefix) for prefix in attr_prefixes] + context_state = {} + for node in set(nodes): + node_attrs = cmds.listAttr(node, st=strings) + if not node_attrs: + # Nothing to do for this node + continue + + node_attr_data = {} + for node_attr in set(node_attrs): + node_attr_data[node_attr] = mapping.get(node_attr, {}) + + if cmds.attributeQuery(usd_json_attr, node=node, exists=True): + existing_node_attr_value = cmds.getAttr( + "{}.{}".format(node, usd_json_attr) + ) + if existing_node_attr_value and existing_node_attr_value != "{}": + # Any existing attribute mappings in an existing + # `USD_UserExportedAttributesJson` attribute always take + # precedence over what this function tries to imprint + existing_node_attr_data = json.loads(existing_node_attr_value) + node_attr_data.update(existing_node_attr_data) + + context_state[node] = json.dumps(node_attr_data) + + sel = om.MSelectionList() + dg_mod = om.MDGModifier() + fn_string = om.MFnStringData() + fn_typed = om.MFnTypedAttribute() + try: + for node, value in context_state.items(): + data = fn_string.create(value) + sel.clear() + if cmds.attributeQuery(usd_json_attr, node=node, exists=True): + # Set the attribute value + sel.add("{}.{}".format(node, usd_json_attr)) + plug = sel.getPlug(0) + dg_mod.newPlugValue(plug, data) + else: + # Create attribute with the value as default value + sel.add(node) + node_obj = sel.getDependNode(0) + attr_obj = fn_typed.create(usd_json_attr, + usd_json_attr, + om.MFnData.kString, + data) + dg_mod.addAttribute(node_obj, attr_obj) + dg_mod.doIt() + yield + finally: + dg_mod.undoIt() + + class ExtractMayaUsd(publish.Extractor): """Extractor for Maya USD Asset data. @@ -126,13 +210,30 @@ class ExtractMayaUsd(publish.Extractor): start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] + def parse_attr_str(attr_str): + result = list() + for attr in attr_str.split(","): + attr = attr.strip() + if not attr: + continue + result.append(attr) + return result + + attrs = parse_attr_str(instance.data.get("attr", "")) + attrs += instance.data.get("userDefinedAttributes", []) + attrs += ["cbId"] + attr_prefixes = parse_attr_str(instance.data.get("attrPrefix", "")) + + self.log.debug('Exporting USD: {} / {}'.format(file_path, members)) with maintained_selection(): - self.log.debug('Exporting USD: {} / {}'.format(file_path, members)) - cmds.mayaUSDExport(file=file_path, - frameRange=(start, end), - frameStride=instance.data.get("step", 1.0), - exportRoots=members, - **options) + with usd_export_attributes(instance[:], + attrs=attrs, + attr_prefixes=attr_prefixes): + cmds.mayaUSDExport(file=file_path, + frameRange=(start, end), + frameStride=instance.data.get("step", 1.0), + exportRoots=members, + **options) representation = { 'name': "usd", From 17d494c1a2cb5229b764daeb415010f97fe46ad4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 23:13:39 +0200 Subject: [PATCH 316/429] Add logic to collect user defined attributes and merge logic with pointcache and animation family + optimize the query by doing only one `cmds.listAttr` call --- .../maya/plugins/publish/collect_animation.py | 14 ------- .../plugins/publish/collect_pointcache.py | 15 ------- .../collect_user_defined_attributes.py | 39 +++++++++++++++++++ 3 files changed, 39 insertions(+), 29 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index 8f523f770b..26a0a01c8b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -58,17 +58,3 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): if instance.data.get("farm"): instance.data["families"].append("publish.farm") - # Collect user defined attributes. - if not instance.data.get("includeUserDefinedAttributes", False): - return - - user_defined_attributes = set() - for node in hierarchy: - attrs = cmds.listAttr(node, userDefined=True) or list() - shapes = cmds.listRelatives(node, shapes=True) or list() - for shape in shapes: - attrs.extend(cmds.listAttr(shape, userDefined=True) or list()) - - user_defined_attributes.update(attrs) - - instance.data["userDefinedAttributes"] = list(user_defined_attributes) diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py index bb9065792f..5578a57f31 100644 --- a/openpype/hosts/maya/plugins/publish/collect_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py @@ -45,18 +45,3 @@ class CollectPointcache(pyblish.api.InstancePlugin): if proxy_set: instance.remove(proxy_set) instance.data["setMembers"].remove(proxy_set) - - # Collect user defined attributes. - if not instance.data.get("includeUserDefinedAttributes", False): - return - - user_defined_attributes = set() - for node in instance: - attrs = cmds.listAttr(node, userDefined=True) or list() - shapes = cmds.listRelatives(node, shapes=True) or list() - for shape in shapes: - attrs.extend(cmds.listAttr(shape, userDefined=True) or list()) - - user_defined_attributes.update(attrs) - - instance.data["userDefinedAttributes"] = list(user_defined_attributes) diff --git a/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py new file mode 100644 index 0000000000..4d0790ad7c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py @@ -0,0 +1,39 @@ +from maya import cmds + +import pyblish.api + + +class CollectUserDefinedAttributes(pyblish.api.InstancePlugin): + """Collect user defined attributes for nodes in instance.""" + + order = pyblish.api.CollectorOrder + 0.4 + families = ["pointcache", "animation", "usd"] + label = "Collect User Defined Attributes" + hosts = ["maya"] + + def process(self, instance): + + # Collect user defined attributes. + if not instance.data.get("includeUserDefinedAttributes", False): + return + + if "out_hierarchy" in instance.data: + # animation family + nodes = instance.data["out_hierarchy"] + else: + nodes = instance[:] + if not nodes: + return + + shapes = cmds.listRelatives(nodes, shapes=True, fullPath=True) or [] + nodes = set(nodes).union(shapes) + + attrs = cmds.listAttr(list(nodes), userDefined=True) or [] + user_defined_attributes = list(sorted(set(attrs))) + instance.data["userDefinedAttributes"] = user_defined_attributes + + self.log.debug( + "Collected user defined attributes: {}".format( + ", ".join(user_defined_attributes) + ) + ) From 4a861a6bfcdc06026fe4146e162523a1dc33cb58 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 23:29:46 +0200 Subject: [PATCH 317/429] Improve docstring --- .../maya/plugins/publish/extract_maya_usd.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py index dfdea6868f..09bbf01831 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -20,6 +20,26 @@ def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): to autogenerate such an attribute during the export to include attributes for the export. + Arguments: + nodes (List[str]): Nodes to process. + attrs (Optional[List[str]]): Full name of attributes to include. + attr_prefixes (Optional[List[str]]): Prefixes of attributes to include. + mapping (Optional[Dict[Dict]]): A mapping per attribute name for the + conversion to a USD attribute, including renaming, defining type, + converting attribute precision, etc. This match the usual + `USD_UserExportedAttributesJson` json mapping of `mayaUSDExport`. + When no mapping provided for an attribute it will use `{}` as + value. + + Examples: + >>> with usd_export_attributes( + >>> ["pCube1"], attrs="myDoubleAttributeAsFloat", mapping={ + >>> "myDoubleAttributeAsFloat": { + >>> "usdAttrName": "my:namespace:attrib", + >>> "translateMayaDoubleToUsdSinglePrecision": True, + >>> } + >>> }) + """ # todo: this might be better done with a custom export chaser # see `chaser` argument for `mayaUSDExport` From a61f7ac7998453dad917d01e43fa134320a8e7e5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 00:02:39 +0200 Subject: [PATCH 318/429] Add support for Look Assigner to assign looks in `aiStandin` for USD files based on `cbId` attributes in the USD file. - For this to currently work the transform and shape should *not* be merged into a single Prim inside USD because otherwise the unique `cbId` between Transform and Shape node will be lost. --- .../tools/mayalookassigner/arnold_standin.py | 8 ++++ .../hosts/maya/tools/mayalookassigner/usd.py | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 openpype/hosts/maya/tools/mayalookassigner/usd.py diff --git a/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py b/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py index 0ce2b21dcd..076b0047bb 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py +++ b/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py @@ -10,6 +10,7 @@ from openpype.client import get_last_version_by_subset_name from openpype.hosts.maya import api from . import lib from .alembic import get_alembic_ids_cache +from .usd import is_usd_lib_supported, get_usd_ids_cache log = logging.getLogger(__name__) @@ -74,6 +75,13 @@ def get_nodes_by_id(standin): # Support alembic files directly return get_alembic_ids_cache(path) + elif ( + is_usd_lib_supported and + any(path.endswith(ext) for ext in [".usd", ".usda", ".usdc"]) + ): + # Support usd files directly + return get_usd_ids_cache(path) + json_path = None for f in os.listdir(os.path.dirname(path)): if f.endswith(".json"): diff --git a/openpype/hosts/maya/tools/mayalookassigner/usd.py b/openpype/hosts/maya/tools/mayalookassigner/usd.py new file mode 100644 index 0000000000..beecbd531a --- /dev/null +++ b/openpype/hosts/maya/tools/mayalookassigner/usd.py @@ -0,0 +1,38 @@ +from collections import defaultdict + +try: + from pxr import Usd + is_usd_lib_supported = True +except ImportError: + is_usd_lib_supported = False + + +def get_usd_ids_cache(path): + # type: (str) -> dict + """Build a id to node mapping in a USD file. + + Nodes without IDs are ignored. + + Returns: + dict: Mapping of id to nodes in the USD file. + + """ + if not is_usd_lib_supported: + raise RuntimeError("No pxr.Usd python library available.") + + stage = Usd.Stage.Open(path) + ids = {} + for prim in stage.Traverse(): + attr = prim.GetAttribute("userProperties:cbId") + if not attr.IsValid(): + continue + path = str(prim.GetPath()) + value = attr.Get() + if not value: + continue + ids[path] = value + + cache = defaultdict(list) + for path, value in ids.items(): + cache[value].append(path) + return dict(cache) From b953391f43592fedf18069093c21e33a35136871 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 00:07:39 +0200 Subject: [PATCH 319/429] Only get path if a value is found --- openpype/hosts/maya/tools/mayalookassigner/usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/tools/mayalookassigner/usd.py b/openpype/hosts/maya/tools/mayalookassigner/usd.py index beecbd531a..6b5cb2f0f5 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/usd.py +++ b/openpype/hosts/maya/tools/mayalookassigner/usd.py @@ -26,10 +26,10 @@ def get_usd_ids_cache(path): attr = prim.GetAttribute("userProperties:cbId") if not attr.IsValid(): continue - path = str(prim.GetPath()) value = attr.Get() if not value: continue + path = str(prim.GetPath()) ids[path] = value cache = defaultdict(list) From 11cd5a874eee0877c16c9cfde8cb8b74f1630364 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 00:13:34 +0200 Subject: [PATCH 320/429] Make sure to run after `CollectPointcache` and `CollectAnimation` --- .../maya/plugins/publish/collect_user_defined_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py index 4d0790ad7c..16fef2e168 100644 --- a/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py +++ b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py @@ -6,7 +6,7 @@ import pyblish.api class CollectUserDefinedAttributes(pyblish.api.InstancePlugin): """Collect user defined attributes for nodes in instance.""" - order = pyblish.api.CollectorOrder + 0.4 + order = pyblish.api.CollectorOrder + 0.45 families = ["pointcache", "animation", "usd"] label = "Collect User Defined Attributes" hosts = ["maya"] From 896b80322cec4158124a8e43c5c5036059393c0b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 6 Sep 2023 03:25:11 +0000 Subject: [PATCH 321/429] [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 7de6fd752b..466f9ce033 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.4" +__version__ = "3.16.5-nightly.5" From 83f4c2ea39012f3dc15890dddf421e86606a4dd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Sep 2023 03:25:50 +0000 Subject: [PATCH 322/429] 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 83a4b6a8d4..f6d1a25dc2 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.5-nightly.5 - 3.16.5-nightly.4 - 3.16.5-nightly.3 - 3.16.5-nightly.2 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.2 - 3.14.9-nightly.1 - 3.14.8 - - 3.14.8-nightly.4 validations: required: true - type: dropdown From 8f5b09af2110bf93169bc69f91989981e8f3ad99 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 15:58:09 +0800 Subject: [PATCH 323/429] adding back the missing function after resolving conflict --- openpype/hosts/max/api/pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index c4167dfe0a..23b89a9ac2 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -15,6 +15,7 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.menu import OpenPypeMenu from openpype.hosts.max.api import lib +from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB from openpype.hosts.max import MAX_HOST_DIR from pymxs import runtime as rt # noqa From caad3e57e67f3f54854c33dd19c6d482fe977f54 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 6 Sep 2023 08:00:16 +0000 Subject: [PATCH 324/429] [Automated] Release --- CHANGELOG.md | 673 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 675 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1948b1a3f..c4f9ff57ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,679 @@ # Changelog +## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.4...3.16.5) + +### **🆕 New features** + + +
+Attribute Definitions: Multiselection enum def #5547 + +Added `multiselection` option to `EnumDef`. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Farm: adding target collector #5494 + +Enhancing farm publishing workflow. + + +___ + +
+ + +
+Maya: Optimize validate plug-in path attributes #5522 + +- Optimize query (use `cmds.ls` once) +- Add Select Invalid action +- Improve validation report +- Avoid "Unknown object type" errors + + +___ + +
+ + +
+Maya: Remove Validate Instance Attributes plug-in #5525 + +Remove Validate Instance Attributes plug-in. + + +___ + +
+ + +
+Enhancement: Tweak logging for artist facing reports #5537 + +Tweak the logging of publishing for global, deadline, maya and a fusion plugin to have a cleaner artist-facing report. +- Fix context being reported correctly from CollectContext +- Fix ValidateMeshArnoldAttributes: fix when arnold is not loaded, fix applying settings, fix for when ai attributes do not exist + + +___ + +
+ + +
+AYON: Update settings #5544 + +Updated settings in AYON addons and conversion of AYON settings in OpenPype. + + +___ + +
+ + +
+Chore: Removed Ass export script #5560 + +Removed Arnold render script, which was obsolete and unused. + + +___ + +
+ + +
+Nuke: Allow for knob values to be validated against multiple values. #5042 + +Knob values can now be validated against multiple values, so you can allow write nodes to be `exr` and `png`, or `16-bit` and `32-bit`. + + +___ + +
+ + +
+Enhancement: Cosmetics for Higher version of publish already exists validation error #5190 + +Fix double spaces in message.Example output **after** the PR: + + +___ + +
+ + +
+Nuke: publish existing frames on farm #5409 + +This PR proposes adding a fourth option in Nuke render publish called "Use Existing Frames - Farm". This would be useful when the farm is busy or when the artist lacks enough farm licenses. Additionally, some artists prefer rendering on the farm but still want to check frames before publishing.By adding the "Use Existing Frames - Farm" option, artists will have more flexibility and control over their render publishing process. This enhancement will streamline the workflow and improve efficiency for Nuke users. + + +___ + +
+ + +
+Unreal: Create project in temp location and move to final when done #5476 + +Create Unreal project in local temporary folder and when done, move it to final destination. + + +___ + +
+ + +
+TrayPublisher: adding audio product type into default presets #5489 + +Adding Audio product type into default presets so anybody can publish audio to their shots. + + +___ + +
+ + +
+Global: avoiding cleanup of flagged representation #5502 + +Publishing folder can be flagged as persistent at representation level. + + +___ + +
+ + +
+General: missing tag could raise error #5511 + +- avoiding potential situation where missing Tag key could raise error + + +___ + +
+ + +
+Chore: Queued event system #5514 + +Implemented event system with more expected behavior of event system. If an event is triggered during other event callback, it is not processed immediately but waits until all callbacks of previous events are done. The event system also allows to not trigger events directly once `emit_event` is called which gives option to process events in custom loops. + + +___ + +
+ + +
+Publisher: Tweak log message to provide plugin name after "Plugin" #5521 + +Fix logged message for settings automatically applied to plugin attributes + + +___ + +
+ + +
+Houdini: Improve VDB Selection #5523 + +Improves VDB selection if selection is `SopNode`: return the selected sop nodeif selection is `ObjNode`: get the output node with the minimum 'outputidx' or the node with display flag + + +___ + +
+ + +
+Maya: Refactor/tweak Validate Instance In same Context plug-in #5526 + +- Chore/Refactor: Re-use existing select invalid and repair actions +- Enhancement: provide more elaborate PublishValidationError report +- Bugfix: fix "optional" support by using `OptionalPyblishPluginMixin` base class. + + +___ + +
+ + +
+Enhancement: Update houdini main menu #5527 + +This PR adds two updates: +- dynamic main menu +- dynamic asset name and task + + +___ + +
+ + +
+Houdini: Reset FPS when clicking Set Frame Range #5528 + +_Similar to Maya,_ Make `Set Frame Range` resets FPS, issue https://github.com/ynput/OpenPype/issues/5516 + + +___ + +
+ + +
+Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools #5531 + +- Fix optional support of validate deadline pools +- Query deadline webservice only once per URL for verification, and once for available deadline pools instead of for every instance +- Use `deadlineUrl` in `instance.data` when validating pools if it is set. +- Code cleanup: Re-use existing `requests_get` implementation + + +___ + +
+ + +
+Chore: PowerShell script for docker build #5535 + +Added PowerShell script to run docker build. + + +___ + +
+ + +
+AYON: Deadline expand userpaths in executables list #5540 + +Expande `~` paths in executables list. + + +___ + +
+ + +
+Chore: Use correct git url #5542 + +Fixed github url in README.md. + + +___ + +
+ + +
+Chore: Create plugin does not expect system settings #5553 + +System settings are not passed to initialization of create plugin initialization (and `apply_settings`). + + +___ + +
+ + +
+Chore: Allow custom Qt scale factor rounding policy #5555 + +Do not force `PassThrough` rounding policy if different policy is defined via env variable. + + +___ + +
+ + +
+Houdini: Fix outdated containers pop-up on opening last workfile on launch #5567 + +Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument + + +___ + +
+ + +
+Houdini: Improve errors e.g. raise PublishValidationError or cosmetics #5568 + +Improve errors e.g. raise PublishValidationError or cosmeticsThis also fixes the Increment Current File plug-in since due to an invalid import it was previously broken + + +___ + +
+ + +
+Fusion: Code updates #5569 + +Update fusion code which contains obsolete code. Removed `switch_ui.py` script from fusion with related script in scripts. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Validate Shape Zero fix repair action + provide informational artist-facing report #5524 + +Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message + + +___ + +
+ + +
+Maya: Fix attribute definitions for `CreateYetiCache` #5574 + +Fix attribute definitions for `CreateYetiCache` + + +___ + +
+ + +
+Max: Optional Renderable Camera Validator for Render Instance #5286 + +Optional validation to check on renderable camera being set up correctly for deadline submission.If not being set up correctly, it wont pass the validation and user can perform repair actions. + + +___ + +
+ + +
+Max: Adding custom modifiers back to the loaded objects #5378 + +The custom parameters OpenpypeData doesn't show in the loaded container when it is being loaded through the loader. + + +___ + +
+ + +
+Houdini: Use default_variant to Houdini Node TAB Creator #5421 + +Use the default variant of the creator plugins on the interactive creator from the TAB node search instead of hard-coding it to `Main`. + + +___ + +
+ + +
+Nuke: adding inherited colorspace from instance #5454 + +Thumbnails are extracted with inherited colorspace collected from rendering write node. + + +___ + +
+ + +
+Add kitsu credentials to deadline publish job #5455 + +This PR hopefully fixes this issue #5440 + + +___ + +
+ + +
+AYON: Fill entities during editorial #5475 + +Fill entities and update template data on instances during extract AYON hierarchy. + + +___ + +
+ + +
+Ftrack: Fix version 0 when integrating to Ftrack - OP-6595 #5477 + +Fix publishing version 0 to Ftrack. + + +___ + +
+ + +
+OCIO: windows unc path support in Nuke and Hiero #5479 + +Hiero and Nuke is not supporting windows unc path formatting in OCIO environment variable. + + +___ + +
+ + +
+Deadline: Added super call to init #5480 + +DL 10.3 requires plugin inheriting from DeadlinePlugin to call super's **init** explicitly. + + +___ + +
+ + +
+Nuke: fixing thumbnail and monitor out root attributes #5483 + +Nuke Root Colorspace settings for Thumbnail and Monitor Out schema was gradually changed between version 12, 13, 14 and we needed to address those changes individually for particular version. + + +___ + +
+ + +
+Nuke: fixing missing `instance_id` error #5484 + +Workfiles with Instances created in old publisher workflow were rising error during converting method since they were missing `instance_id` key introduced in new publisher workflow. + + +___ + +
+ + +
+Nuke: existing frames validator is repairing render target #5486 + +Nuke is now correctly repairing render target after the existing frames validator finds missing frames and repair action is used. + + +___ + +
+ + +
+added UE to extract burnins families #5487 + +This PR fixes missing burnins in reviewables when rendering from UE. +___ + +
+ + +
+Harmony: refresh code for current Deadline #5493 + +- Added support in Deadline Plug-in for new versions of Harmony, in particular version 21 and 22. +- Remove review=False flag on render instance +- Add farm=True flag on render instance +- Fix is_in_tests function call in Harmony Deadline submission plugin +- Force HarmonyOpenPype.py Deadline Python plug-in to py3 +- Fix cosmetics/hound in HarmonyOpenPype.py Deadline Python plug-in + + +___ + +
+ + +
+Publisher: Fix multiselection value #5505 + +Selection of multiple instances in Publisher does not cause that all instances change all publish attributes to the same value. + + +___ + +
+ + +
+Publisher: Avoid warnings on thumbnails if source image also has alpha channel #5510 + +Avoids the following warning from `ExtractThumbnailFromSource`: +``` +// pyblish.ExtractThumbnailFromSource : oiiotool WARNING: -o : Can't save 4 channels to jpeg... saving only R,G,B +``` + + + +___ + +
+ + +
+Update ayon-python-api #5512 + +Update ayon python api and related callbacks. + + +___ + +
+ + +
+Max: Fixing the bug of falling back to use workfile for Arnold or any renderers except Redshift #5520 + +Fix the bug of falling back to use workfile for Arnold + + +___ + +
+ + +
+General: Fix Validate Publish Dir Validator #5534 + +Nonsensical "family" key was used instead of real value (as 'render' etc.) which would result in wrong translation of intermediate family names.Updated docstring. + + +___ + +
+ + +
+have the addons loading respect a custom AYON_ADDONS_DIR #5539 + +When using a custom AYON_ADDONS_DIR environment variable that variable is used in the launcher correctly and downloads and extracts addons to there, however when running Ayon does not respect this environment variable + + +___ + +
+ + +
+Deadline: files on representation cannot be single item list #5545 + +Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"].This would cause an issue in ExtractReview later.This could happen if DL rendered single frame file with different frame value. + + +___ + +
+ + +
+Webpublisher: better encode list values for click #5546 + +Targets could be a list, original implementation pushed it as a separate items, it must be added as `--targets webpulish --targets filepublish`.`wepublish_routes` handles triggering from UI, changes in `publish_functions` handle triggering from cmd (for tests, api access). + + +___ + +
+ + +
+Houdini: Introduce imprint function for correct version in hda loader #5548 + +Resolve #5478 + + +___ + +
+ + +
+AYON: Fill entities during editorial (2) #5549 + +Fix changes made in https://github.com/ynput/OpenPype/pull/5475. + + +___ + +
+ + +
+Max: OP Data updates in Loaders #5563 + +Fix the bug on the loaders not being able to load the objects when iterating key and values with the dict.Max prefers list over the list in dict. + + +___ + +
+ + +
+Create Plugins: Better check of overriden '__init__' method #5571 + +Create plugins do not log warning messages about each create plugin because of wrong `__init__` method check. + + +___ + +
+ +### **Merged pull requests** + + +
+Tests: fix unit tests #5533 + +Fixed failing tests.Updated Unreal's validator to match removed general one which had a couple of issues fixed. + + +___ + +
+ + + + ## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4) diff --git a/openpype/version.py b/openpype/version.py index 466f9ce033..d5d46bab0c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.5" +__version__ = "3.16.5" diff --git a/pyproject.toml b/pyproject.toml index a07c547123..68fbf19c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.4" # OpenPype +version = "3.16.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 1c667f91f90dbd3f34df971ece4453efc57ff0a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Sep 2023 08:01:23 +0000 Subject: [PATCH 325/429] 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 f6d1a25dc2..a35dbf1a17 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.5 - 3.16.5-nightly.5 - 3.16.5-nightly.4 - 3.16.5-nightly.3 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.3 - 3.14.9-nightly.2 - 3.14.9-nightly.1 - - 3.14.8 validations: required: true - type: dropdown From 9f8cd773bbc07e07db0536430735f97ae668fcf7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 6 Sep 2023 10:13:22 +0200 Subject: [PATCH 326/429] use start value instead of current value --- openpype/tools/publisher/widgets/screenshot_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 4ccf920571..170345f170 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -46,7 +46,7 @@ class ScreenMarquee(QtWidgets.QDialog): for screen in QtWidgets.QApplication.screens(): screen.geometryChanged.connect(self._fit_screen_geometry) - self._opacity = fade_anim.currentValue() + self._opacity = fade_anim.startValue() self._click_pos = None self._capture_rect = None From 2315ef84f1325a157f55bf85797d3e9719373392 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 6 Sep 2023 10:13:35 +0200 Subject: [PATCH 327/429] do not start animation on init --- openpype/tools/publisher/widgets/screenshot_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 170345f170..64cccece6c 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -31,7 +31,6 @@ class ScreenMarquee(QtWidgets.QDialog): fade_anim.setEndValue(50) fade_anim.setDuration(200) fade_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic) - fade_anim.start(QtCore.QAbstractAnimation.DeleteWhenStopped) fade_anim.valueChanged.connect(self._on_fade_anim) From 862907079c7c143854e864270878afb40449060e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 15:23:11 +0200 Subject: [PATCH 328/429] Support special creator attributes in Maya's flattened `creator_attributes` structure that are not convertable to Maya native attribute types (list, tuple, dict), like e.g. `EnumDef` with `multiselection=True` --- openpype/hosts/maya/api/plugin.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 058637c8b5..770767fc7d 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -161,8 +161,17 @@ class MayaCreatorBase(object): # will not clash in names with `subset`, `task`, etc. and other # default names. This is just so these attributes in many cases # are still editable in the maya UI by artists. - # pop to move to end of dict to sort attributes last on the node + # note: pop to move to end of dict to sort attributes last on the node creator_attributes = data.pop("creator_attributes", {}) + + # We only flatten value types which `imprint` function supports + json_creator_attributes = {} + for key, value in dict(creator_attributes).items(): + if isinstance(value, (list, tuple, dict)): + creator_attributes.pop(key) + json_creator_attributes[key] = value + + # Flatten remaining creator attributes to the node itself data.update(creator_attributes) # We know the "publish_attributes" will be complex data of @@ -172,6 +181,10 @@ class MayaCreatorBase(object): data.pop("publish_attributes", {}) ) + # Persist the non-flattened creator attributes (special value types, + # like multiselection EnumDef) + data["creator_attributes"] = json.dumps(json_creator_attributes) + # Since we flattened the data structure for creator attributes we want # to correctly detect which flattened attributes should end back in the # creator attributes when reading the data from the node, so we store @@ -192,15 +205,22 @@ class MayaCreatorBase(object): # being read as 'data' node_data.pop("cbId", None) + # Make sure we convert any creator attributes from the json string + creator_attributes = node_data.get("creator_attributes") + if creator_attributes: + node_data["creator_attributes"] = json.loads(creator_attributes) + else: + node_data["creator_attributes"] = {} + # Move the relevant attributes into "creator_attributes" that # we flattened originally - node_data["creator_attributes"] = {} creator_attribute_keys = node_data.pop("__creator_attributes_keys", "").split(",") for key in creator_attribute_keys: if key in node_data: node_data["creator_attributes"][key] = node_data.pop(key) + # Make sure we convert any publish attributes from the json string publish_attributes = node_data.get("publish_attributes") if publish_attributes: node_data["publish_attributes"] = json.loads(publish_attributes) From 48ab18f5431a42bdd185f25a7bedab5b45770ad9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 21:25:50 +0800 Subject: [PATCH 329/429] Ondrej's comment on changing the raise exception to LoaderError --- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index d3b7c61ff8..d87d6b1bfe 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -1,10 +1,12 @@ import os from openpype.pipeline import load, get_representation_path +from openpype.pipeline.load import LoaderError from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data ) + from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, @@ -28,8 +30,8 @@ class OxAbcLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): plugin_list = get_plugins() if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list: - raise RuntimeError("Ornatrix plugin not " - "found/installed in Max yet..") + raise LoaderError("Ornatrix plugin not " + "found/installed in Max yet..") file_path = os.path.normpath(self.filepath_from_context(context)) rt.AlembicImport.ImportToRoot = True From a0f7951ea3927de61722d299fb77239abfd9e033 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 16:02:46 +0200 Subject: [PATCH 330/429] Add job context parameter to USD publisher E.g. You can now directly export with a `Arnold` job context (if it's registered) so that the USD export is Arnold supported and directly renderable with shaders/render attributes by Arnold renderer. --- .../maya/plugins/create/create_maya_usd.py | 25 ++++++++++++++++++- .../maya/plugins/publish/extract_maya_usd.py | 2 ++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py index f05f155dd9..38de218bfb 100644 --- a/openpype/hosts/maya/plugins/create/create_maya_usd.py +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -5,6 +5,8 @@ from openpype.lib import ( TextDef ) +from maya import cmds + class CreateMayaUsd(plugin.MayaCreator): """Create Maya USD Export""" @@ -15,11 +17,28 @@ class CreateMayaUsd(plugin.MayaCreator): icon = "cubes" description = "Create Maya USD Export" + cache = {} + def get_publish_families(self): return ["usd", "mayaUsd"] def get_instance_attr_defs(self): + if "jobContextItems" not in self.cache: + # Query once instead of per instance + job_context_items = {} + try: + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + job_context_items = { + cmds.mayaUSDListJobContexts(jobContext=name): name + for name in cmds.mayaUSDListJobContexts(export=True) + } + except RuntimeError: + # Likely `mayaUsdPlugin` plug-in not available + self.log.warning("Unable to retrieve available job " + "contexts for `mayaUsdPlugin` exports") + self.cache["jobContextItems"] = job_context_items + defs = lib.collect_animation_defs() defs.extend([ EnumDef("defaultUSDFormat", @@ -45,7 +64,11 @@ class CreateMayaUsd(plugin.MayaCreator): TextDef("attrPrefix", label="Custom Attributes Prefix", default="", - placeholder="prefix1, prefix2") + placeholder="prefix1, prefix2"), + EnumDef("jobContext", + label="Job Context", + items=self.cache["jobContextItems"], + multiselection=True), ]) return defs diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py index 09bbf01831..8c32ac1e39 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -148,6 +148,7 @@ class ExtractMayaUsd(publish.Extractor): "exportRefsAsInstanceable": bool, "eulerFilter": bool, "renderableOnly": bool, + "jobContext": (list, None) # optional list # "worldspace": bool, } @@ -169,6 +170,7 @@ class ExtractMayaUsd(publish.Extractor): "exportRefsAsInstanceable": False, "eulerFilter": True, "renderableOnly": False, + "jobContext": None # "worldspace": False } From 388ae935e1e37d5771816defd4983bb1d6092bd2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 16:09:51 +0200 Subject: [PATCH 331/429] By default load proxy as *not* `Shareable` --- openpype/hosts/maya/plugins/load/load_maya_usd.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_maya_usd.py b/openpype/hosts/maya/plugins/load/load_maya_usd.py index 26c497768d..2fb1a625a5 100644 --- a/openpype/hosts/maya/plugins/load/load_maya_usd.py +++ b/openpype/hosts/maya/plugins/load/load_maya_usd.py @@ -51,6 +51,14 @@ class MayaUsdLoader(load.LoaderPlugin): cmds.connectAttr("time1.outTime", "{}.time".format(proxy)) cmds.setAttr("{}.filePath".format(proxy), path, type="string") + # By default, we force the proxy to not use a shared stage because + # when doing so Maya will quite easily allow to save into the + # loaded usd file. Since we are loading published files we want to + # avoid altering them. Unshared stages also save their edits into + # the workfile as an artist might expect it to do. + cmds.setAttr("{}.shareStage".format(proxy), False) + # cmds.setAttr("{}.shareStage".format(proxy), lock=True) + nodes = [transform, proxy] self[:] = nodes From 8dd4b70aa3c153ece4cb26fb4ee590742850c990 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 6 Sep 2023 16:52:34 +0200 Subject: [PATCH 332/429] nuke: remove redundant workfile colorspace profiles --- .../defaults/project_settings/nuke.json | 6 +----- .../schemas/schema_nuke_imageio.json | 20 ------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index b736c462ff..7961e77113 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -28,11 +28,7 @@ "colorManagement": "Nuke", "OCIO_config": "nuke-default", "workingSpaceLUT": "linear", - "monitorLut": "sRGB", - "int8Lut": "sRGB", - "int16Lut": "sRGB", - "logLut": "Cineon", - "floatLut": "linear" + "monitorLut": "sRGB" }, "nodes": { "requiredNodes": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json index d4cd332ef8..af826fcf46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json @@ -106,26 +106,6 @@ "type": "text", "key": "monitorLut", "label": "monitor" - }, - { - "type": "text", - "key": "int8Lut", - "label": "8-bit files" - }, - { - "type": "text", - "key": "int16Lut", - "label": "16-bit files" - }, - { - "type": "text", - "key": "logLut", - "label": "log files" - }, - { - "type": "text", - "key": "floatLut", - "label": "float files" } ] } From 359ead27a2e6f637dda576ae1daf1801bf00f348 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 6 Sep 2023 16:53:41 +0200 Subject: [PATCH 333/429] nuke-addon: remove redundant workfile colorspace names --- server_addon/nuke/server/settings/imageio.py | 32 -------------------- 1 file changed, 32 deletions(-) diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index b43017ef8b..3f5ac0b8f9 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -66,22 +66,6 @@ def ocio_configs_switcher_enum(): class WorkfileColorspaceSettings(BaseSettingsModel): """Nuke workfile colorspace preset. """ - """# TODO: enhance settings with host api: - we need to add mapping to resolve properly keys. - Nuke is excpecting camel case key names, - but for better code consistency we need to - be using snake_case: - - color_management = colorManagement - ocio_config = OCIO_config - working_space_name = workingSpaceLUT - monitor_name = monitorLut - monitor_out_name = monitorOutLut - int_8_name = int8Lut - int_16_name = int16Lut - log_name = logLut - float_name = floatLut - """ colorManagement: Literal["Nuke", "OCIO"] = Field( title="Color Management" @@ -100,18 +84,6 @@ class WorkfileColorspaceSettings(BaseSettingsModel): monitorLut: str = Field( title="Monitor" ) - int8Lut: str = Field( - title="8-bit files" - ) - int16Lut: str = Field( - title="16-bit files" - ) - logLut: str = Field( - title="Log files" - ) - floatLut: str = Field( - title="Float files" - ) class ReadColorspaceRulesItems(BaseSettingsModel): @@ -238,10 +210,6 @@ DEFAULT_IMAGEIO_SETTINGS = { "OCIO_config": "nuke-default", "workingSpaceLUT": "linear", "monitorLut": "sRGB", - "int8Lut": "sRGB", - "int16Lut": "sRGB", - "logLut": "Cineon", - "floatLut": "linear" }, "nodes": { "requiredNodes": [ From f1267546d23040eb778b9f8ead135d6de36184d9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 18:02:24 +0200 Subject: [PATCH 334/429] Avoid error if no job contexts are available --- openpype/hosts/maya/plugins/create/create_maya_usd.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py index 38de218bfb..77a96dd4cb 100644 --- a/openpype/hosts/maya/plugins/create/create_maya_usd.py +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -31,12 +31,17 @@ class CreateMayaUsd(plugin.MayaCreator): cmds.loadPlugin("mayaUsdPlugin", quiet=True) job_context_items = { cmds.mayaUSDListJobContexts(jobContext=name): name - for name in cmds.mayaUSDListJobContexts(export=True) + for name in cmds.mayaUSDListJobContexts(export=True) or [] } except RuntimeError: # Likely `mayaUsdPlugin` plug-in not available self.log.warning("Unable to retrieve available job " "contexts for `mayaUsdPlugin` exports") + + if not job_context_items: + # enumdef multiselection may not be empty + job_context_items = [""] + self.cache["jobContextItems"] = job_context_items defs = lib.collect_animation_defs() From d5823ab556e016c424b7c4bdd12ddac646d745be Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 18:08:51 +0200 Subject: [PATCH 335/429] Add a few tooltips --- .../maya/plugins/create/create_maya_usd.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py index 77a96dd4cb..cc9a14bd3a 100644 --- a/openpype/hosts/maya/plugins/create/create_maya_usd.py +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -55,12 +55,29 @@ class CreateMayaUsd(plugin.MayaCreator): default="usdc"), BoolDef("stripNamespaces", label="Strip Namespaces", + tooltip=( + "Remove namespaces during export. By default, " + "namespaces are exported to the USD file in the " + "following format: nameSpaceExample_pPlatonic1" + ), default=True), BoolDef("mergeTransformAndShape", label="Merge Transform and Shape", + tooltip=( + "Combine Maya transform and shape into a single USD" + "prim that has transform and geometry, for all" + " \"geometric primitives\" (gprims).\n" + "This results in smaller and faster scenes. Gprims " + "will be \"unpacked\" back into transform and shape " + "nodes when imported into Maya from USD." + ), default=True), BoolDef("includeUserDefinedAttributes", label="Include User Defined Attributes", + tooltip=( + "Whether to include all custom maya attributes found " + "on nodes as metadata (userProperties) in USD." + ), default=False), TextDef("attr", label="Custom Attributes", @@ -73,6 +90,12 @@ class CreateMayaUsd(plugin.MayaCreator): EnumDef("jobContext", label="Job Context", items=self.cache["jobContextItems"], + tooltip=( + "Specifies an additional export context to handle.\n" + "These usually contain extra schemas, primitives,\n" + "and materials that are to be exported for a " + "specific\ntask, a target renderer for example." + ), multiselection=True), ]) From d2140c36192d353d68c287af6385fac1b8fa68d7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 6 Sep 2023 21:37:20 +0300 Subject: [PATCH 336/429] match maya render mask in houdini --- openpype/hosts/houdini/api/lib.py | 53 ++++++++++++ openpype/hosts/houdini/api/pipeline.py | 2 + .../plugins/inventory/set_asset_resolution.py | 24 ++++++ .../hosts/houdini/plugins/load/load_camera.py | 81 +++++++++++-------- 4 files changed, 126 insertions(+), 34 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 75c7ff9fee..c6672cf969 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -649,3 +649,56 @@ def get_color_management_preferences(): "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } + + +def get_current_asset_doc(): + """Get asset document of the current asset. """ + + project_name = get_current_project_name() + asset_name = get_current_asset_name() + asset_doc = get_asset_by_name(project_name, asset_name) + + return asset_doc + + +def get_resolution_from_data(doc): + if not doc or "data" not in doc: + print("Entered document is not valid. \"{}\"".format(str(doc))) + return None + + resolution_width = doc["data"].get("resolutionWidth") + resolution_height = doc["data"].get("resolutionHeight") + + # Make sure both width and height are set + if resolution_width is None or resolution_height is None: + print("No resolution information found for \"{}\"".format(doc["name"])) + return None + + return int(resolution_width), int(resolution_height) + + +def set_camera_resolution(camera, asset_doc=None): + """Apply resolution to camera from asset document of the publish""" + + if not asset_doc: + asset_doc = get_current_asset_doc() + + resolution = get_resolution_from_data(asset_doc) + + if resolution: + print("Setting camera resolution: {} -> {}x{}".format( + camera.name(), resolution[0], resolution[1] + )) + camera.parm("resx").set(resolution[0]) + camera.parm("resy").set(resolution[1]) + + +def get_camera_from_container(container): + """Get camera from container node. """ + + cameras = container.recursiveGlob("*", + filter=hou.nodeTypeFilter.ObjCamera, + include_subnets=False) + + assert len(cameras) == 1, "Camera instance must have only one camera" + return cameras[0] diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index c9ae801af5..6aa65deb89 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -14,6 +14,7 @@ import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, + register_inventory_action_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers @@ -55,6 +56,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) + register_inventory_action_path(INVENTORY_PATH) log.info("Installing callbacks ... ") # register_event_callback("init", on_init) diff --git a/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py new file mode 100644 index 0000000000..cff7f89288 --- /dev/null +++ b/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py @@ -0,0 +1,24 @@ +from openpype.pipeline import InventoryAction +from openpype.hosts.houdini.api.lib import ( + get_camera_from_container, + set_camera_resolution +) + +class SetAssetResolution(InventoryAction): + + label = "Set Asset Resolution" + icon = "desktop" + color = "orange" + + @staticmethod + def is_compatible(container): + print(container) + return ( + container.get("loader") == "CameraLoader" + ) + + def process(self, containers): + for container in containers: + node = container["node"] + camera = get_camera_from_container(node) + set_camera_resolution(camera) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 7b4a04809e..cffa5ca813 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -4,6 +4,13 @@ from openpype.pipeline import ( ) from openpype.hosts.houdini.api import pipeline +from openpype.hosts.houdini.api.lib import ( + set_camera_resolution, + get_camera_from_container +) + +import hou + ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' '.alembicGetCameraDict') @@ -25,7 +32,15 @@ def transfer_non_default_values(src, dest, ignore=None): channel expression and ignore certain Parm types. """ - import hou + + ignore_types = { + hou.parmTemplateType.Toggle, + hou.parmTemplateType.Menu, + hou.parmTemplateType.Button, + hou.parmTemplateType.FolderSet, + hou.parmTemplateType.Separator, + hou.parmTemplateType.Label, + } src.updateParmStates() @@ -62,14 +77,6 @@ def transfer_non_default_values(src, dest, ignore=None): continue # Ignore folders, separators, etc. - ignore_types = { - hou.parmTemplateType.Toggle, - hou.parmTemplateType.Menu, - hou.parmTemplateType.Button, - hou.parmTemplateType.FolderSet, - hou.parmTemplateType.Separator, - hou.parmTemplateType.Label, - } if parm.parmTemplate().type() in ignore_types: continue @@ -90,13 +97,8 @@ class CameraLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - import os - import hou - # Format file name, Houdini only wants forward slashes - file_path = self.filepath_from_context(context) - file_path = os.path.normpath(file_path) - file_path = file_path.replace("\\", "/") + file_path = self.fname.replace("\\", "/") # Get the root node obj = hou.node("/obj") @@ -106,19 +108,21 @@ class CameraLoader(load.LoaderPlugin): node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node - container = self.create_and_connect(obj, "alembicarchive", node_name) + node = self.create_and_connect(obj, "alembicarchive", node_name) # TODO: add FPS of project / asset - container.setParms({"fileName": file_path, - "channelRef": True}) + node.setParms({"fileName": file_path, "channelRef": True}) # Apply some magic - container.parm("buildHierarchy").pressButton() - container.moveToGoodPosition() + node.parm("buildHierarchy").pressButton() + node.moveToGoodPosition() # Create an alembic xform node - nodes = [container] + nodes = [node] + camera = get_camera_from_container(node) + self._match_maya_render_mask(camera) + set_camera_resolution(camera, asset_doc=context["asset"]) self[:] = nodes return pipeline.containerise(node_name, @@ -143,14 +147,14 @@ class CameraLoader(load.LoaderPlugin): # Store the cam temporarily next to the Alembic Archive # so that we can preserve parm values the user set on it # after build hierarchy was triggered. - old_camera = self._get_camera(node) + old_camera = get_camera_from_container(node) temp_camera = old_camera.copyTo(node.parent()) # Rebuild node.parm("buildHierarchy").pressButton() # Apply values to the new camera - new_camera = self._get_camera(node) + new_camera = get_camera_from_container(node) transfer_non_default_values(temp_camera, new_camera, # The hidden uniform scale attribute @@ -158,6 +162,9 @@ class CameraLoader(load.LoaderPlugin): # "icon_scale" just skip that completely ignore={"scale"}) + self._match_maya_render_mask(new_camera) + set_camera_resolution(new_camera) + temp_camera.destroy() def remove(self, container): @@ -165,15 +172,6 @@ class CameraLoader(load.LoaderPlugin): node = container["node"] node.destroy() - def _get_camera(self, node): - import hou - cameras = node.recursiveGlob("*", - filter=hou.nodeTypeFilter.ObjCamera, - include_subnets=False) - - assert len(cameras) == 1, "Camera instance must have only one camera" - return cameras[0] - def create_and_connect(self, node, node_type, name=None): """Create a node within a node which and connect it to the input @@ -194,5 +192,20 @@ class CameraLoader(load.LoaderPlugin): new_node.moveToGoodPosition() return new_node - def switch(self, container, representation): - self.update(container, representation) + def _match_maya_render_mask(self, camera): + """Workaround to match Maya render mask in Houdini""" + + # print("Setting match maya render mask ") + parm = camera.parm("aperture") + expression = parm.expression() + expression = expression.replace("return ", "aperture = ") + expression += """ +# Match maya render mask (logic from Houdini's own FBX importer) +node = hou.pwd() +resx = node.evalParm('resx') +resy = node.evalParm('resy') +aspect = node.evalParm('aspect') +aperture *= min(1, (resx / resy * aspect) / 1.5) +return aperture +""" + parm.setExpression(expression, language=hou.exprLanguage.Python) From 32a192b1929598c82fc8d199b19ff0edb5763564 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 6 Sep 2023 21:44:46 +0300 Subject: [PATCH 337/429] change action name --- .../{set_asset_resolution.py => set_camera_resolution.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename openpype/hosts/houdini/plugins/inventory/{set_asset_resolution.py => set_camera_resolution.py} (87%) diff --git a/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py similarity index 87% rename from openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py rename to openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index cff7f89288..441aef8b1c 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -4,9 +4,9 @@ from openpype.hosts.houdini.api.lib import ( set_camera_resolution ) -class SetAssetResolution(InventoryAction): +class SetCameraResolution(InventoryAction): - label = "Set Asset Resolution" + label = "Set Camera Resolution" icon = "desktop" color = "orange" From f8d03955f71b6d7fe9d87b1835cb1d62a2adf788 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 6 Sep 2023 21:59:11 +0300 Subject: [PATCH 338/429] resolve hound --- openpype/hosts/houdini/api/lib.py | 14 +++++++++----- .../plugins/inventory/set_camera_resolution.py | 1 + openpype/hosts/houdini/plugins/load/load_camera.py | 14 +++++++------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index c6672cf969..f83519ddb8 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -661,7 +661,9 @@ def get_current_asset_doc(): return asset_doc -def get_resolution_from_data(doc): +def get_resolution_from_doc(doc): + """Get resolution from the given asset document. """ + if not doc or "data" not in doc: print("Entered document is not valid. \"{}\"".format(str(doc))) return None @@ -683,7 +685,7 @@ def set_camera_resolution(camera, asset_doc=None): if not asset_doc: asset_doc = get_current_asset_doc() - resolution = get_resolution_from_data(asset_doc) + resolution = get_resolution_from_doc(asset_doc) if resolution: print("Setting camera resolution: {} -> {}x{}".format( @@ -696,9 +698,11 @@ def set_camera_resolution(camera, asset_doc=None): def get_camera_from_container(container): """Get camera from container node. """ - cameras = container.recursiveGlob("*", - filter=hou.nodeTypeFilter.ObjCamera, - include_subnets=False) + cameras = container.recursiveGlob( + "*", + filter=hou.nodeTypeFilter.ObjCamera, + include_subnets=False + ) assert len(cameras) == 1, "Camera instance must have only one camera" return cameras[0] diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index 441aef8b1c..5dd94232b8 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -4,6 +4,7 @@ from openpype.hosts.houdini.api.lib import ( set_camera_resolution ) + class SetCameraResolution(InventoryAction): label = "Set Camera Resolution" diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index cffa5ca813..53567b6f97 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -34,13 +34,13 @@ def transfer_non_default_values(src, dest, ignore=None): """ ignore_types = { - hou.parmTemplateType.Toggle, - hou.parmTemplateType.Menu, - hou.parmTemplateType.Button, - hou.parmTemplateType.FolderSet, - hou.parmTemplateType.Separator, - hou.parmTemplateType.Label, - } + hou.parmTemplateType.Toggle, + hou.parmTemplateType.Menu, + hou.parmTemplateType.Button, + hou.parmTemplateType.FolderSet, + hou.parmTemplateType.Separator, + hou.parmTemplateType.Label, + } src.updateParmStates() From 135cb285120495c250a8cbbace25a8581445fb1e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 22:17:21 +0200 Subject: [PATCH 339/429] Add deprecation warning to usage of `fname` on Loader plugins --- openpype/pipeline/load/plugins.py | 13 +++++++++++++ openpype/pipeline/load/utils.py | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index f87fb3312d..8acfcfdb6c 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -234,6 +234,19 @@ class LoaderPlugin(list): """ return cls.options or [] + @property + def fname(self): + """Backwards compatibility with deprecation warning""" + + self.log.warning(( + "DEPRECATION WARNING: Source - Loader plugin {}." + " The 'fname' property on the Loader plugin will be removed in" + " future versions of OpenPype. Planned version to drop the support" + " is 3.16.6 or 3.17.0." + ).format(self.__class__.__name__)) + if hasattr(self, "_fname"): + return self._fname + class SubsetLoaderPlugin(LoaderPlugin): """Load subset into host application diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 42418be40e..b10d6032b3 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -318,7 +318,8 @@ def load_with_repre_context( # Backwards compatibility: Originally the loader's __init__ required the # representation context to set `fname` attribute to the filename to load - loader.fname = get_representation_path_from_context(repre_context) + # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0. + loader._fname = get_representation_path_from_context(repre_context) return loader.load(repre_context, name, namespace, options) From f954c877023f902a7ab28d2d86401829883734c0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 22:17:56 +0200 Subject: [PATCH 340/429] Refactor usage of deprecated `self.fname` to new style --- openpype/hosts/blender/plugins/load/load_blend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 99f291a5a7..fa41f4374b 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -119,7 +119,7 @@ class BlendLoader(plugin.AssetLoader): context: Full parenthood of representation to load options: Additional settings dictionary """ - libpath = self.fname + libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] From ba6dfc5eadd1a4d48f92924a2e1bcf3c7d5524f6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:28:39 +0200 Subject: [PATCH 341/429] Remove unused variables + tweak logs --- .../hosts/blender/plugins/load/load_camera_abc.py | 2 +- .../hosts/blender/plugins/load/load_camera_fbx.py | 2 +- .../hosts/blender/plugins/publish/extract_abc.py | 2 -- .../plugins/publish/extract_abc_animation.py | 2 -- .../blender/plugins/publish/extract_camera_abc.py | 5 ----- openpype/hosts/flame/plugins/load/load_clip.py | 1 - .../hosts/flame/plugins/load/load_clip_batch.py | 1 - .../plugins/publish/collect_timeline_instances.py | 1 - .../hosts/harmony/plugins/load/load_template.py | 1 - openpype/hosts/hiero/api/plugin.py | 14 -------------- .../hiero/plugins/publish/collect_clip_effects.py | 2 -- openpype/hosts/houdini/plugins/load/load_bgeo.py | 1 - openpype/hosts/max/plugins/create/create_render.py | 1 - .../hosts/max/plugins/publish/collect_render.py | 1 - .../max/plugins/publish/extract_camera_abc.py | 2 -- .../max/plugins/publish/extract_camera_fbx.py | 3 +-- .../max/plugins/publish/extract_max_scene_raw.py | 3 +-- .../hosts/max/plugins/publish/extract_model.py | 4 +--- .../hosts/max/plugins/publish/extract_model_fbx.py | 5 +---- .../hosts/max/plugins/publish/extract_model_obj.py | 4 +--- .../max/plugins/publish/extract_pointcache.py | 2 -- .../max/plugins/publish/extract_redshift_proxy.py | 3 +-- openpype/hosts/maya/api/plugin.py | 1 - .../maya/plugins/inventory/import_reference.py | 1 - .../hosts/maya/plugins/load/load_multiverse_usd.py | 2 -- openpype/hosts/maya/plugins/load/load_reference.py | 4 ++-- openpype/hosts/maya/plugins/load/load_xgen.py | 2 -- .../plugins/publish/collect_multiverse_look.py | 1 - .../hosts/maya/tools/mayalookassigner/widgets.py | 4 +--- .../hosts/nuke/plugins/load/load_camera_abc.py | 2 -- .../plugins/publish/extract_review_data_lut.py | 1 - .../nuke/plugins/publish/extract_thumbnail.py | 2 -- .../plugins/publish/collect_auto_image.py | 1 - openpype/hosts/resolve/api/plugin.py | 10 ---------- .../plugins/publish/extract_thumbnail.py | 2 -- .../tvpaint/plugins/load/load_reference_image.py | 2 +- .../tvpaint/plugins/publish/extract_sequence.py | 3 --- .../hosts/unreal/plugins/publish/extract_uasset.py | 3 +-- 38 files changed, 13 insertions(+), 90 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_camera_abc.py b/openpype/hosts/blender/plugins/load/load_camera_abc.py index e5afecff66..05d3fb764d 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_abc.py +++ b/openpype/hosts/blender/plugins/load/load_camera_abc.py @@ -100,7 +100,7 @@ class AbcCameraLoader(plugin.AssetLoader): asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name) + self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index b9d05dda0a..3cca6e7fd3 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -103,7 +103,7 @@ class FbxCameraLoader(plugin.AssetLoader): asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name) + self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index f4babc94d3..87159e53f0 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -21,8 +21,6 @@ class ExtractABC(publish.Extractor): filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction self.log.info("Performing extraction..") diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py index e141ccaa44..44b2ba3761 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py @@ -20,8 +20,6 @@ class ExtractAnimationABC(publish.Extractor): filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction self.log.info("Performing extraction..") diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py index a21a59b151..036be7bf3c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py @@ -21,16 +21,11 @@ class ExtractCameraABC(publish.Extractor): filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction self.log.info("Performing extraction..") plugin.deselect_all() - selected = [] - active = None - asset_group = None for obj in instance: if obj.get(AVALON_PROPERTY): diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 338833b449..ca4eab0f63 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -48,7 +48,6 @@ class LoadClip(opfapi.ClipLoader): self.fpd = fproject.current_workspace.desktop # load clip to timeline and get main variables - namespace = namespace version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index ca43b94ee9..1f3a017d72 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -45,7 +45,6 @@ class LoadClipBatch(opfapi.ClipLoader): self.batch = options.get("batch") or flame.batch # load clip to timeline and get main variables - namespace = namespace version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 23fdf5e785..e14f960a2b 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -325,7 +325,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): def _create_shot_instance(self, context, clip_name, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") - asset = data.get("asset") if not master_layer: return diff --git a/openpype/hosts/harmony/plugins/load/load_template.py b/openpype/hosts/harmony/plugins/load/load_template.py index f3c69a9104..a78a1bf1ec 100644 --- a/openpype/hosts/harmony/plugins/load/load_template.py +++ b/openpype/hosts/harmony/plugins/load/load_template.py @@ -82,7 +82,6 @@ class TemplateLoader(load.LoaderPlugin): node = harmony.find_node_by_name(node_name, "GROUP") self_name = self.__class__.__name__ - update_and_replace = False if is_representation_from_latest(representation): self._set_green(node) else: diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 65a4009756..52f96261b2 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -317,20 +317,6 @@ class Spacer(QtWidgets.QWidget): self.setLayout(layout) -def get_reference_node_parents(ref): - """Return all parent reference nodes of reference node - - Args: - ref (str): reference node. - - Returns: - list: The upstream parent reference nodes. - - """ - parents = [] - return parents - - class SequenceLoader(LoaderPlugin): """A basic SequenceLoader for Resolve diff --git a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py index d455ad4a4e..fcb1ab27a0 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py @@ -43,7 +43,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin): if review and review_track_index == _track_index: continue for sitem in sub_track_items: - effect = None # make sure this subtrack item is relative of track item if ((track_item not in sitem.linkedItems()) and (len(sitem.linkedItems()) > 0)): @@ -53,7 +52,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin): continue effect = self.add_effect(_track_index, sitem) - if effect: effects.update(effect) diff --git a/openpype/hosts/houdini/plugins/load/load_bgeo.py b/openpype/hosts/houdini/plugins/load/load_bgeo.py index 22680178c0..489bf944ed 100644 --- a/openpype/hosts/houdini/plugins/load/load_bgeo.py +++ b/openpype/hosts/houdini/plugins/load/load_bgeo.py @@ -34,7 +34,6 @@ class BgeoLoader(load.LoaderPlugin): # Create a new geo node container = obj.createNode("geo", node_name=node_name) - is_sequence = bool(context["representation"]["context"].get("frame")) # Remove the file node, it only loads static meshes # Houdini 17 has removed the file node from the geo node diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 235046684e..9cc3c8da8a 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -14,7 +14,6 @@ class CreateRender(plugin.MaxCreator): def create(self, subset_name, instance_data, pre_create_data): from pymxs import runtime as rt - sel_obj = list(rt.selection) file = rt.maxFileName filename, _ = os.path.splitext(file) instance_data["AssetName"] = filename diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 8ee2f43103..2dfa1520a9 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -30,7 +30,6 @@ class CollectRender(pyblish.api.InstancePlugin): asset = get_current_asset_name() files_by_aov = RenderProducts().get_beauty(instance.name) - folder = folder.replace("\\", "/") aovs = RenderProducts().get_aovs(instance.name) files_by_aov.update(aovs) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index b42732e70d..b1918c53e0 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -22,8 +22,6 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): start = float(instance.data.get("frameStartHandle", 1)) end = float(instance.data.get("frameEndHandle", 1)) - container = instance.data["instance_node"] - self.log.info("Extracting Camera ...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py index 06ac3da093..537c88eb4d 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py @@ -19,9 +19,8 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - self.log.info("Extracting Camera ...") + self.log.debug("Extracting Camera ...") stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py index de5db9ab56..a7a889c587 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -18,10 +18,9 @@ class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - container = instance.data["instance_node"] # publish the raw scene for camera - self.log.info("Extracting Raw Max Scene ...") + self.log.debug("Extracting Raw Max Scene ...") stagingdir = self.staging_dir(instance) filename = "{name}.max".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model.py b/openpype/hosts/max/plugins/publish/extract_model.py index c7ecf7efc9..38f4848c5e 100644 --- a/openpype/hosts/max/plugins/publish/extract_model.py +++ b/openpype/hosts/max/plugins/publish/extract_model.py @@ -20,9 +20,7 @@ class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_model_fbx.py index 56c2cadd94..fd48ed5007 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_model_fbx.py @@ -20,10 +20,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py index 4fde65cf22..e522b1e7a1 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_obj.py +++ b/openpype/hosts/max/plugins/publish/extract_model_obj.py @@ -20,9 +20,7 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.obj".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index 5a99a8b845..c3de623bc0 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -54,8 +54,6 @@ class ExtractAlembic(publish.Extractor): start = float(instance.data.get("frameStartHandle", 1)) end = float(instance.data.get("frameEndHandle", 1)) - container = instance.data["instance_node"] - self.log.debug("Extracting pointcache ...") parent_dir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index ab569ecbcb..f67ed30c6b 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,11 +16,10 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - container = instance.data["instance_node"] start = int(instance.context.data.get("frameStart")) end = int(instance.context.data.get("frameEnd")) - self.log.info("Extracting Redshift Proxy...") + self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) rs_filename = "{name}.rs".format(**instance.data) rs_filepath = os.path.join(stagingdir, rs_filename) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3f383fafb8..4032618afb 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -683,7 +683,6 @@ class ReferenceLoader(Loader): loaded_containers.append(container) self._organize_containers(nodes, container) c += 1 - namespace = None return loaded_containers diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py index ecc424209d..3f3b85ba6c 100644 --- a/openpype/hosts/maya/plugins/inventory/import_reference.py +++ b/openpype/hosts/maya/plugins/inventory/import_reference.py @@ -12,7 +12,6 @@ class ImportReference(InventoryAction): color = "#d8d8d8" def process(self, containers): - references = cmds.ls(type="reference") for container in containers: if container["loader"] != "ReferenceLoader": print("Not a reference, skipping") diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index d08fcd904e..cad42b55f9 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -43,8 +43,6 @@ class MultiverseUsdLoader(load.LoaderPlugin): import multiverse # Create the shape - shape = None - transform = None with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 91767249e0..61f337f501 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -205,7 +205,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): cmds.setAttr("{}.selectHandleZ".format(group_name), cz) if family == "rig": - self._post_process_rig(name, namespace, context, options) + self._post_process_rig(namespace, context, options) else: if "translate" in options: if not attach_to_root and new_nodes: @@ -229,7 +229,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): members = get_container_members(container) self._lock_camera_transforms(members) - def _post_process_rig(self, name, namespace, context, options): + def _post_process_rig(self, namespace, context, options): nodes = self[:] create_rig_animation_instance( diff --git a/openpype/hosts/maya/plugins/load/load_xgen.py b/openpype/hosts/maya/plugins/load/load_xgen.py index 323f8d7eda..2ad6ad55bc 100644 --- a/openpype/hosts/maya/plugins/load/load_xgen.py +++ b/openpype/hosts/maya/plugins/load/load_xgen.py @@ -53,8 +53,6 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): ) # Reference xgen. Xgen does not like being referenced in under a group. - new_nodes = [] - with maintained_selection(): nodes = cmds.file( maya_filepath, diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py index f05fb76d48..bcb979edfc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py @@ -281,7 +281,6 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): long=True) nodes.update(nodes_of_interest) - files = [] sets = {} instance.data["resources"] = [] publishMipMap = instance.data["publishMipMap"] diff --git a/openpype/hosts/maya/tools/mayalookassigner/widgets.py b/openpype/hosts/maya/tools/mayalookassigner/widgets.py index f2df17e68c..82c37e2104 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/widgets.py +++ b/openpype/hosts/maya/tools/mayalookassigner/widgets.py @@ -90,15 +90,13 @@ class AssetOutliner(QtWidgets.QWidget): def get_all_assets(self): """Add all items from the current scene""" - items = [] with preserve_expanded_rows(self.view): with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) self.add_items(items) - - return len(items) > 0 + return len(items) > 0 def get_selected_assets(self): """Add all selected items from the current scene""" diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index fec4ee556e..2939ceebae 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -112,8 +112,6 @@ class AlembicCameraLoader(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) object_name = container['objectName'] - # get corresponding node - camera_node = nuke.toNode(object_name) # get main variables version_data = version_doc.get("data", {}) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index e4b7b155cd..2a26ed82fb 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -20,7 +20,6 @@ class ExtractReviewDataLut(publish.Extractor): hosts = ["nuke"] def process(self, instance): - families = instance.data["families"] self.log.info("Creating staging dir...") if "representations" in instance.data: staging_dir = instance.data[ diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index d57d55f85d..b20df4ffe2 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -91,8 +91,6 @@ class ExtractThumbnail(publish.Extractor): if collection: # get path - fname = os.path.basename(collection.format( - "{head}{padding}{tail}")) fhead = collection.format("{head}") thumb_fname = list(collection)[mid_frame] diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py index f1d8419608..77f1a3e91f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py @@ -16,7 +16,6 @@ class CollectAutoImage(pyblish.api.ContextPlugin): targets = ["automated"] def process(self, context): - family = "image" for instance in context: creator_identifier = instance.data.get("creator_identifier") if creator_identifier and creator_identifier == "auto_image": diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 59c27f29da..e2bd76ffa2 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -413,8 +413,6 @@ class ClipLoader: if self.with_handles: source_in -= handle_start source_out += handle_end - handle_start = 0 - handle_end = 0 # make track item from source in bin as item timeline_item = lib.create_timeline_item( @@ -433,14 +431,6 @@ class ClipLoader: self.data["path"], self.active_bin) _clip_property = media_pool_item.GetClipProperty - # get handles - handle_start = self.data["versionData"].get("handleStart") - handle_end = self.data["versionData"].get("handleEnd") - if handle_start is None: - handle_start = int(self.data["assetData"]["handleStart"]) - if handle_end is None: - handle_end = int(self.data["assetData"]["handleEnd"]) - source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index b99503b3c8..a2afd160fa 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -49,8 +49,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): else: first_filename = files - staging_dir = None - # Convert to jpeg if not yet full_input_path = os.path.join( thumbnail_repre["stagingDir"], first_filename diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py index edc116a8e4..3707ef97aa 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py @@ -171,7 +171,7 @@ class LoadImage(plugin.Loader): george_script = "\n".join(george_script_lines) execute_george_through_file(george_script) - def _remove_container(self, container, members=None): + def _remove_container(self, container): if not container: return representation = container["representation"] diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 8a610cf388..a13a91de46 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -63,7 +63,6 @@ class ExtractSequence(pyblish.api.Extractor): "ignoreLayersTransparency", False ) - family_lowered = instance.data["family"].lower() mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] @@ -76,11 +75,9 @@ class ExtractSequence(pyblish.api.Extractor): # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) - frame_end = int(instance.data["frameEnd"]) # Handles are not stored per instance but on Context handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] scene_bg_color = instance.context.data["sceneBgColor"] diff --git a/openpype/hosts/unreal/plugins/publish/extract_uasset.py b/openpype/hosts/unreal/plugins/publish/extract_uasset.py index 48b62faa97..0dd7ff4a0d 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_uasset.py +++ b/openpype/hosts/unreal/plugins/publish/extract_uasset.py @@ -19,9 +19,8 @@ class ExtractUAsset(publish.Extractor): "umap" if "umap" in instance.data.get("families") else "uasset") ar = unreal.AssetRegistryHelpers.get_asset_registry() - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") staging_dir = self.staging_dir(instance) - filename = f"{instance.name}.{extension}" members = instance.data.get("members", []) From e79f1ef4b9b48f085f42ac43344404580522d10c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:30:17 +0200 Subject: [PATCH 342/429] Replacing value only if value is in `str` is same as just replacing it --- .../hosts/houdini/plugins/publish/collect_vray_rop.py | 9 ++------- openpype/hosts/maya/api/lib_rendersettings.py | 9 ++------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index d4fe37f993..277f922ba4 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -80,14 +80,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): def get_beauty_render_product(self, prefix, suffix=""): """Return the beauty output filename if render element enabled """ + # Remove aov suffix from the product: `prefix.aov_suffix` -> `prefix` aov_parm = ".{}".format(suffix) - beauty_product = None - if aov_parm in prefix: - beauty_product = prefix.replace(aov_parm, "") - else: - beauty_product = prefix - - return beauty_product + return prefix.replace(aov_parm, "") def get_render_element_name(self, node, prefix, suffix=""): """Return the output filename using the AOV prefix and suffix diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index f54633c04d..42cf29d0a7 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -177,12 +177,7 @@ class RenderSettings(object): # list all the aovs all_rs_aovs = cmds.ls(type='RedshiftAOV') for rs_aov in redshift_aovs: - rs_layername = rs_aov - if " " in rs_aov: - rs_renderlayer = rs_aov.replace(" ", "") - rs_layername = "rsAov_{}".format(rs_renderlayer) - else: - rs_layername = "rsAov_{}".format(rs_aov) + rs_layername = "rsAov_{}".format(rs_aov.replace(" ", "")) if rs_layername in all_rs_aovs: continue cmds.rsCreateAov(type=rs_aov) @@ -317,7 +312,7 @@ class RenderSettings(object): separators = [cmds.menuItem(i, query=True, label=True) for i in items] # noqa: E501 try: sep_idx = separators.index(aov_separator) - except ValueError as e: + except ValueError: six.reraise( CreatorError, CreatorError( From c1b305a4462b6e58eede2853a5cd0623cf4ef925 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:31:33 +0200 Subject: [PATCH 343/429] Fix code (`if not found` should not have been nest into the for loop) + simplify logic --- openpype/hosts/max/api/lib_rendersettings.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py index afde5008d5..26e176aa8d 100644 --- a/openpype/hosts/max/api/lib_rendersettings.py +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -37,13 +37,10 @@ class RenderSettings(object): def set_render_camera(self, selection): for sel in selection: # to avoid Attribute Error from pymxs wrapper - found = False if rt.classOf(sel) in rt.Camera.classes: - found = True rt.viewport.setCamera(sel) - break - if not found: - raise RuntimeError("Active Camera not found") + return + raise RuntimeError("Active Camera not found") def render_output(self, container): folder = rt.maxFilePath From 60334621988ee749369d222d11ecd972871a2573 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:32:53 +0200 Subject: [PATCH 344/429] Use asset doc and project doc from instance/context + tweak logic for values --- .../publish/validate_resolution_setting.py | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py index 5fcb843b20..5ac41b10a0 100644 --- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py +++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py @@ -6,11 +6,6 @@ from openpype.pipeline import ( from pymxs import runtime as rt from openpype.hosts.max.api.lib import reset_scene_resolution -from openpype.pipeline.context_tools import ( - get_current_project_asset, - get_current_project -) - class ValidateResolutionSetting(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): @@ -43,22 +38,16 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, "on asset or shot.") def get_db_resolution(self, instance): - data = ["data.resolutionWidth", "data.resolutionHeight"] - project_resolution = get_current_project(fields=data) - project_resolution_data = project_resolution["data"] - asset_resolution = get_current_project_asset(fields=data) - asset_resolution_data = asset_resolution["data"] - # Set project resolution - project_width = int( - project_resolution_data.get("resolutionWidth", 1920)) - project_height = int( - project_resolution_data.get("resolutionHeight", 1080)) - width = int( - asset_resolution_data.get("resolutionWidth", project_width)) - height = int( - asset_resolution_data.get("resolutionHeight", project_height)) + asset_doc = instance.data["assetEntity"] + project_doc = instance.context.data["projectEntity"] + for data in [asset_doc["data"], project_doc["data"]]: + if "resolutionWidth" in data and "resolutionHeight" in data: + width = data["resolutionWidth"] + height = data["resolutionHeight"] + return int(width), int(height) - return width, height + # Defaults if not found in asset document or project document + return 1920, 1080 @classmethod def repair(cls, instance): From 91a4ae5dfd39d64db3ded9e98b750b8d1afe22bc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:34:01 +0200 Subject: [PATCH 345/429] Remove (not yet deprecated?) `system_settings` from `apply_settings` --- .../hosts/maya/plugins/publish/extract_import_reference.py | 4 ++-- openpype/hosts/maya/plugins/publish/validate_maya_units.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_import_reference.py b/openpype/hosts/maya/plugins/publish/extract_import_reference.py index 9d2ff1a3eb..1fdee28d0c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_import_reference.py +++ b/openpype/hosts/maya/plugins/publish/extract_import_reference.py @@ -30,8 +30,8 @@ class ExtractImportReference(publish.Extractor, tmp_format = "_tmp" @classmethod - def apply_settings(cls, project_setting, system_settings): - cls.active = project_setting["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa + def apply_settings(cls, project_settings): + cls.active = project_settings["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa def process(self, instance): if not self.is_active(instance.data): diff --git a/openpype/hosts/maya/plugins/publish/validate_maya_units.py b/openpype/hosts/maya/plugins/publish/validate_maya_units.py index 1d5619795f..ae6dc093a9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_maya_units.py +++ b/openpype/hosts/maya/plugins/publish/validate_maya_units.py @@ -37,7 +37,7 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin): ) @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["publish"]["ValidateMayaUnits"] From 7a3aaa5408b3d4a9ff22220018506e25e55858bf Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 10:26:09 +0300 Subject: [PATCH 346/429] replace fname --- openpype/hosts/houdini/plugins/load/load_camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 53567b6f97..e16146a267 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -98,7 +98,7 @@ class CameraLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): # Format file name, Houdini only wants forward slashes - file_path = self.fname.replace("\\", "/") + file_path = self.filepath_from_context(context).replace("\\", "/") # Get the root node obj = hou.node("/obj") From b601da0dbba469c1a6920622218d74ada0f589f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 7 Sep 2023 11:40:59 +0200 Subject: [PATCH 347/429] do not fix folder in representation context --- openpype/client/server/conversion_utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index a6c190a0fc..2dca9ad57d 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -663,8 +663,8 @@ def convert_v4_representation_to_v3(representation): if isinstance(context, six.string_types): context = json.loads(context) - if "folder" in context: - _c_folder = context.pop("folder") + if "asset" not in context and "folder" in context: + _c_folder = context["folder"] context["asset"] = _c_folder["name"] if "product" in context: @@ -959,9 +959,11 @@ def convert_create_representation_to_v4(representation, con): converted_representation["files"] = new_files context = representation["context"] - context["folder"] = { - "name": context.pop("asset", None) - } + if "folder" not in context: + context["folder"] = { + "name": context.get("asset") + } + context["product"] = { "type": context.pop("family", None), "name": context.pop("subset", None), @@ -1285,7 +1287,7 @@ def convert_update_representation_to_v4( if "context" in update_data: context = update_data["context"] - if "asset" in context: + if "folder" not in context and "asset" in context: context["folder"] = {"name": context.pop("asset")} if "family" in context or "subset" in context: From 7cde83933a22437d3ba4a570f3c2a77f0396e810 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 7 Sep 2023 11:50:16 +0200 Subject: [PATCH 348/429] autofix wrong representations --- openpype/client/server/conversion_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index 2dca9ad57d..f67a1ef9c4 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -667,6 +667,9 @@ def convert_v4_representation_to_v3(representation): _c_folder = context["folder"] context["asset"] = _c_folder["name"] + elif "asset" in context and "folder" not in context: + context["folder"] = {"name": context["asset"]} + if "product" in context: _c_product = context.pop("product") context["family"] = _c_product["type"] From 2c0a8d41006c5886d0e05e85c302f00b70c8e8f9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 7 Sep 2023 14:47:27 +0200 Subject: [PATCH 349/429] adding default factory to list objects --- .../nuke/server/settings/create_plugins.py | 4 ++++ server_addon/nuke/server/settings/imageio.py | 2 ++ .../nuke/server/settings/publish_plugins.py | 3 +++ .../nuke/server/settings/workfile_builder.py | 20 ++++++++++++++----- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server_addon/nuke/server/settings/create_plugins.py b/server_addon/nuke/server/settings/create_plugins.py index 0bbae4ee77..b89188a77a 100644 --- a/server_addon/nuke/server/settings/create_plugins.py +++ b/server_addon/nuke/server/settings/create_plugins.py @@ -37,6 +37,7 @@ class PrenodeModel(BaseSettingsModel): - We could not support v3 style of settings. """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -66,6 +67,7 @@ class CreateWriteRenderModel(BaseSettingsModel): (we could not support v3 style of settings) """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) @@ -95,6 +97,7 @@ class CreateWritePrerenderModel(BaseSettingsModel): (we could not support v3 style of settings) """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) @@ -124,6 +127,7 @@ class CreateWriteImageModel(BaseSettingsModel): (we could not support v3 style of settings) """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index b43017ef8b..16f9bd309a 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -14,6 +14,7 @@ class NodesModel(BaseSettingsModel): """ _layout = "expanded" plugins: list[str] = Field( + default_factory=list, title="Used in plugins" ) # TODO: rename `nukeNodeClass` to `nuke_node_class` @@ -25,6 +26,7 @@ class NodesModel(BaseSettingsModel): in nuke integration. We could not support v3 style of settings. """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 7e898f8c9a..de21870d5f 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -46,6 +46,7 @@ class NodeModel(BaseSettingsModel): - We could not support v3 style of settings. """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -105,6 +106,7 @@ class ExtractThumbnailModel(BaseSettingsModel): """ nodes: list[NodeModel] = Field( + default_factory=list, title="Nodes (deprecated)" ) reposition_nodes: list[ThumbnailRepositionNodeModel] = Field( @@ -177,6 +179,7 @@ class ExtractReviewDataMovModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") outputs: list[BakingStreamModel] = Field( + default_factory=list, title="Baking streams" ) diff --git a/server_addon/nuke/server/settings/workfile_builder.py b/server_addon/nuke/server/settings/workfile_builder.py index ee67c7c16a..e9ac0db2a0 100644 --- a/server_addon/nuke/server/settings/workfile_builder.py +++ b/server_addon/nuke/server/settings/workfile_builder.py @@ -48,20 +48,30 @@ class BuilderProfileModel(BaseSettingsModel): title="Task names" ) current_context: list[BuilderProfileItemModel] = Field( - title="Current context") + default_factory=list, + title="Current context" + ) linked_assets: list[BuilderProfileItemModel] = Field( - title="Linked assets/shots") + default_factory=list, + title="Linked assets/shots" + ) class WorkfileBuilderModel(BaseSettingsModel): create_first_version: bool = Field( title="Create first workfile") custom_templates: list[CustomTemplateModel] = Field( - title="Custom templates") + default_factory=list, + title="Custom templates" + ) builder_on_start: bool = Field( - title="Run Builder at first workfile") + default=False, + title="Run Builder at first workfile" + ) profiles: list[BuilderProfileModel] = Field( - title="Builder profiles") + default_factory=list, + title="Builder profiles" + ) DEFAULT_WORKFILE_BUILDER_SETTINGS = { From ee050fe63ba6c498fd736bcf2e1bcb1226e49ed9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 7 Sep 2023 14:47:47 +0200 Subject: [PATCH 350/429] clearing todos and improving docstrings --- server_addon/nuke/server/settings/common.py | 6 ------ .../nuke/server/settings/create_plugins.py | 19 ------------------- server_addon/nuke/server/settings/dirmap.py | 13 ------------- server_addon/nuke/server/settings/imageio.py | 15 ++++----------- .../nuke/server/settings/loader_plugins.py | 8 -------- server_addon/nuke/server/settings/main.py | 4 +--- .../nuke/server/settings/publish_plugins.py | 17 ----------------- .../nuke/server/settings/scriptsmenu.py | 1 - .../settings/templated_workfile_build.py | 1 + .../nuke/server/settings/workfile_builder.py | 2 ++ 10 files changed, 8 insertions(+), 78 deletions(-) diff --git a/server_addon/nuke/server/settings/common.py b/server_addon/nuke/server/settings/common.py index 700f01f3dc..2bc3c9be81 100644 --- a/server_addon/nuke/server/settings/common.py +++ b/server_addon/nuke/server/settings/common.py @@ -89,12 +89,6 @@ knob_types_enum = [ class KnobModel(BaseSettingsModel): - """# TODO: new data structure - - v3 was having type, name, value but - ayon is not able to make it the same. Current model is - defining `type` as `text` and instead of `value` the key is `text`. - So if `type` is `boolean` then key is `boolean` (value). - """ _layout = "expanded" type: str = Field( diff --git a/server_addon/nuke/server/settings/create_plugins.py b/server_addon/nuke/server/settings/create_plugins.py index b89188a77a..80aec51ae0 100644 --- a/server_addon/nuke/server/settings/create_plugins.py +++ b/server_addon/nuke/server/settings/create_plugins.py @@ -16,13 +16,10 @@ def instance_attributes_enum(): class PrenodeModel(BaseSettingsModel): - # TODO: missing in host api - # - good for `dependency` name: str = Field( title="Node name" ) - # TODO: `nodeclass` should be renamed to `nuke_node_class` nodeclass: str = Field( "", title="Node class" @@ -32,10 +29,6 @@ class PrenodeModel(BaseSettingsModel): title="Incoming dependency" ) - """# TODO: Changes in host api: - - Need complete rework of knob types in nuke integration. - - We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -62,10 +55,6 @@ class CreateWriteRenderModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( default_factory=list, title="Preceding nodes", @@ -92,10 +81,6 @@ class CreateWritePrerenderModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( default_factory=list, title="Preceding nodes", @@ -122,10 +107,6 @@ class CreateWriteImageModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( default_factory=list, title="Preceding nodes", diff --git a/server_addon/nuke/server/settings/dirmap.py b/server_addon/nuke/server/settings/dirmap.py index 2da6d7bf60..7e3c443957 100644 --- a/server_addon/nuke/server/settings/dirmap.py +++ b/server_addon/nuke/server/settings/dirmap.py @@ -25,19 +25,6 @@ class DirmapSettings(BaseSettingsModel): ) -"""# TODO: -nuke is having originally implemented -following data inputs: - -"nuke-dirmap": { - "enabled": false, - "paths": { - "source-path": [], - "destination-path": [] - } -} -""" - DEFAULT_DIRMAP_SETTINGS = { "enabled": False, "paths": { diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index 16f9bd309a..44bb72769e 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -9,22 +9,15 @@ from .common import KnobModel class NodesModel(BaseSettingsModel): - """# TODO: This needs to be somehow labeled in settings panel - or at least it could show gist of configuration - """ _layout = "expanded" plugins: list[str] = Field( default_factory=list, title="Used in plugins" ) - # TODO: rename `nukeNodeClass` to `nuke_node_class` nukeNodeClass: str = Field( title="Nuke Node Class", ) - """ # TODO: Need complete rework of knob types - in nuke integration. We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -172,7 +165,7 @@ class ImageIOSettings(BaseSettingsModel): _isGroup: bool = True """# TODO: enhance settings with host api: - to restruture settings for simplification. + to restructure settings for simplification. now: nuke/imageio/viewer/viewerProcess future: nuke/imageio/viewer @@ -195,7 +188,7 @@ class ImageIOSettings(BaseSettingsModel): ) """# TODO: enhance settings with host api: - to restruture settings for simplification. + to restructure settings for simplification. now: nuke/imageio/baking/viewerProcess future: nuke/imageio/baking @@ -217,9 +210,9 @@ class ImageIOSettings(BaseSettingsModel): title="Nodes" ) """# TODO: enhance settings with host api: - - old settings are using `regexInputs` key but we + - [ ] old settings are using `regexInputs` key but we need to rename to `regex_inputs` - - no need for `inputs` middle part. It can stay + - [ ] no need for `inputs` middle part. It can stay directly on `regex_inputs` """ regexInputs: RegexInputsModel = Field( diff --git a/server_addon/nuke/server/settings/loader_plugins.py b/server_addon/nuke/server/settings/loader_plugins.py index 6db381bffb..51e2c2149b 100644 --- a/server_addon/nuke/server/settings/loader_plugins.py +++ b/server_addon/nuke/server/settings/loader_plugins.py @@ -6,10 +6,6 @@ class LoadImageModel(BaseSettingsModel): enabled: bool = Field( title="Enabled" ) - """# TODO: v3 api used `_representation` - New api is hiding it so it had to be renamed - to `representations_include` - """ representations_include: list[str] = Field( default_factory=list, title="Include representations" @@ -33,10 +29,6 @@ class LoadClipModel(BaseSettingsModel): enabled: bool = Field( title="Enabled" ) - """# TODO: v3 api used `_representation` - New api is hiding it so it had to be renamed - to `representations_include` - """ representations_include: list[str] = Field( default_factory=list, title="Include representations" diff --git a/server_addon/nuke/server/settings/main.py b/server_addon/nuke/server/settings/main.py index 4687d48ac9..cdaaa3a9e2 100644 --- a/server_addon/nuke/server/settings/main.py +++ b/server_addon/nuke/server/settings/main.py @@ -59,9 +59,7 @@ class NukeSettings(BaseSettingsModel): default_factory=ImageIOSettings, title="Color Management (imageio)", ) - """# TODO: fix host api: - - rename `nuke-dirmap` to `dirmap` was inevitable - """ + dirmap: DirmapSettings = Field( default_factory=DirmapSettings, title="Nuke Directory Mapping", diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index de21870d5f..c78685534f 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -28,11 +28,9 @@ def nuke_product_types_enum(): class NodeModel(BaseSettingsModel): - # TODO: missing in host api name: str = Field( title="Node name" ) - # TODO: `nodeclass` rename to `nuke_node_class` nodeclass: str = Field( "", title="Node class" @@ -41,10 +39,6 @@ class NodeModel(BaseSettingsModel): "", title="Incoming dependency" ) - """# TODO: Changes in host api: - - Need complete rework of knob types in nuke integration. - - We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -100,10 +94,6 @@ class ExtractThumbnailModel(BaseSettingsModel): use_rendered: bool = Field(title="Use rendered images") bake_viewer_process: bool = Field(title="Bake view process") bake_viewer_input_process: bool = Field(title="Bake viewer input process") - """# TODO: needs to rewrite from v3 to ayon - - `nodes` in v3 was dict but now `prenodes` is list of dict - - also later `nodes` should be `prenodes` - """ nodes: list[NodeModel] = Field( default_factory=list, @@ -216,12 +206,6 @@ class ExctractSlateFrameParamModel(BaseSettingsModel): class ExtractSlateFrameModel(BaseSettingsModel): viewer_lut_raw: bool = Field(title="Viewer lut raw") - """# TODO: v3 api different model: - - not possible to replicate v3 model: - {"name": [bool, str]} - - not it is: - {"name": {"enabled": bool, "template": str}} - """ key_value_mapping: ExctractSlateFrameParamModel = Field( title="Key value mapping", default_factory=ExctractSlateFrameParamModel @@ -290,7 +274,6 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Slate Frame", default_factory=ExtractSlateFrameModel ) - # TODO: plugin should be renamed - `workfile` not `script` IncrementScriptVersion: IncrementScriptVersionModel = Field( title="Increment Workfile Version", default_factory=IncrementScriptVersionModel, diff --git a/server_addon/nuke/server/settings/scriptsmenu.py b/server_addon/nuke/server/settings/scriptsmenu.py index 9d1c32ebac..0b2d660da5 100644 --- a/server_addon/nuke/server/settings/scriptsmenu.py +++ b/server_addon/nuke/server/settings/scriptsmenu.py @@ -17,7 +17,6 @@ class ScriptsmenuSettings(BaseSettingsModel): """Nuke script menu project settings.""" _isGroup = True - # TODO: in api rename key `name` to `menu_name` name: str = Field(title="Menu Name") definition: list[ScriptsmenuSubmodel] = Field( default_factory=list, diff --git a/server_addon/nuke/server/settings/templated_workfile_build.py b/server_addon/nuke/server/settings/templated_workfile_build.py index e0245c8d06..0899be841e 100644 --- a/server_addon/nuke/server/settings/templated_workfile_build.py +++ b/server_addon/nuke/server/settings/templated_workfile_build.py @@ -28,6 +28,7 @@ class TemplatedWorkfileProfileModel(BaseSettingsModel): class TemplatedWorkfileBuildModel(BaseSettingsModel): + """Settings for templated workfile builder.""" profiles: list[TemplatedWorkfileProfileModel] = Field( default_factory=list ) diff --git a/server_addon/nuke/server/settings/workfile_builder.py b/server_addon/nuke/server/settings/workfile_builder.py index e9ac0db2a0..3ae3b08788 100644 --- a/server_addon/nuke/server/settings/workfile_builder.py +++ b/server_addon/nuke/server/settings/workfile_builder.py @@ -58,6 +58,8 @@ class BuilderProfileModel(BaseSettingsModel): class WorkfileBuilderModel(BaseSettingsModel): + """[deprecated] use Template Workfile Build Settings instead. + """ create_first_version: bool = Field( title="Create first workfile") custom_templates: list[CustomTemplateModel] = Field( From 0836b21116690fa1c7f11dfb0c5e24391a558d6a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 14:23:51 +0200 Subject: [PATCH 351/429] Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed - Collect the rig sets only once (I've ordered it before Collect History so that the instance still contains less node, as an optimization) - Also fixes a hard error when `out_SET` is not found, instead now only relevant `PublishValidationError` are raised to generate a nice report --- .../maya/plugins/publish/collect_rig_sets.py | 39 +++++++++++++++++ .../plugins/publish/validate_rig_contents.py | 42 +++++++++++++------ .../publish/validate_rig_controllers.py | 36 +++++++++++----- ...idate_rig_controllers_arnold_attributes.py | 6 +-- .../publish/validate_rig_out_set_node_ids.py | 11 +++-- .../publish/validate_rig_output_ids.py | 5 ++- 6 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/collect_rig_sets.py diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py new file mode 100644 index 0000000000..36a4211af1 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py @@ -0,0 +1,39 @@ +import pyblish.api +from maya import cmds + + +class CollectRigSets(pyblish.api.InstancePlugin): + """Ensure rig contains pipeline-critical content + + Every rig must contain at least two object sets: + "controls_SET" - Set of all animatable controls + "out_SET" - Set of all cacheable meshes + + """ + + order = pyblish.api.CollectorOrder + 0.05 + label = "Collect Rig Sets" + hosts = ["maya"] + families = ["rig"] + + accepted_output = ["mesh", "transform"] + accepted_controllers = ["transform"] + + def process(self, instance): + + # Find required sets by suffix + searching = {"controls_SET", "out_SET"} + found = {} + for node in cmds.ls(instance, exactType="objectSet"): + for suffix in searching: + if node.endswith(suffix): + found[suffix] = node + searching.remove(suffix) + break + if not searching: + break + + self.log.debug("Found sets: {}".format(found)) + rig_sets = instance.data.setdefault("rig_sets", {}) + for name, objset in found.items(): + rig_sets[name] = objset diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 7b5392f8f9..23f031a5db 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -2,7 +2,9 @@ import pyblish.api from maya import cmds from openpype.pipeline.publish import ( - PublishValidationError, ValidateContentsOrder) + PublishValidationError, + ValidateContentsOrder +) class ValidateRigContents(pyblish.api.InstancePlugin): @@ -24,31 +26,45 @@ class ValidateRigContents(pyblish.api.InstancePlugin): def process(self, instance): - objectsets = ("controls_SET", "out_SET") - missing = [obj for obj in objectsets if obj not in instance] - assert not missing, ("%s is missing %s" % (instance, missing)) + # Find required sets by suffix + required = ["controls_SET", "out_SET"] + missing = [ + key for key in required if key not in instance.data["rig_sets"] + ] + if missing: + raise PublishValidationError( + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) + + controls_set = instance.data["rig_sets"]["controls_SET"] + out_set = instance.data["rig_sets"]["out_SET"] # Ensure there are at least some transforms or dag nodes # in the rig instance set_members = instance.data['setMembers'] if not cmds.ls(set_members, type="dagNode", long=True): raise PublishValidationError( - ("No dag nodes in the pointcache instance. " - "(Empty instance?)")) + "No dag nodes in the pointcache instance. " + "(Empty instance?)" + ) # Ensure contents in sets and retrieve long path for all objects - output_content = cmds.sets("out_SET", query=True) or [] - assert output_content, "Must have members in rig out_SET" + output_content = cmds.sets(out_set, query=True) or [] + if not output_content: + raise PublishValidationError("Must have members in rig out_SET") output_content = cmds.ls(output_content, long=True) - controls_content = cmds.sets("controls_SET", query=True) or [] - assert controls_content, "Must have members in rig controls_SET" + controls_content = cmds.sets(controls_set, query=True) or [] + if not controls_content: + raise PublishValidationError( + "Must have members in rig controls_SET" + ) controls_content = cmds.ls(controls_content, long=True) # Validate members are inside the hierarchy from root node - root_node = cmds.ls(set_members, assemblies=True) - hierarchy = cmds.listRelatives(root_node, allDescendents=True, - fullPath=True) + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes hierarchy = set(hierarchy) invalid_hierarchy = [] diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 7bbf4257ab..a3828f871b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -52,22 +52,30 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError('{} failed, see log ' - 'information'.format(self.label)) + raise PublishValidationError( + '{} failed, see log information'.format(self.label) + ) @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] - controls = cmds.sets(controllers_sets, query=True) - assert controls, "Must have 'controls_SET' in rig instance" + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: + cls.log.error( + "Must have 'controls_SET' in rig instance" + ) + return [instance.data["instance_node"]] + + controls = cmds.sets(controls_set, query=True) # Ensure all controls are within the top group lookup = set(instance[:]) - assert all(control in lookup for control in cmds.ls(controls, - long=True)), ( - "All controls must be inside the rig's group." - ) + if not all(control in lookup for control in cmds.ls(controls, + long=True)): + cls.log.error( + "All controls must be inside the rig's group." + ) + return [controls_set] # Validate all controls has_connections = list() @@ -181,9 +189,17 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: + cls.log.error( + "Unable to repair because no 'controls_SET' found in rig " + "instance: {}".format(instance) + ) + return + # Use a single undo chunk with undo_chunk(): - controls = cmds.sets("controls_SET", query=True) + controls = cmds.sets(controls_set, query=True) for control in controls: # Lock visibility diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py index 842c1de01b..03f6a5f1ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py @@ -56,11 +56,11 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] - if not controllers_sets: + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: return [] - controls = cmds.sets(controllers_sets, query=True) or [] + controls = cmds.sets(controls_set, query=True) or [] if not controls: return [] diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 39f0941faa..fbd510c683 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -38,16 +38,19 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): # if a deformer has been created on the shape invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Nodes found with mismatching " - "IDs: {0}".format(invalid)) + raise PublishValidationError( + "Nodes found with mismatching IDs: {0}".format(invalid) + ) @classmethod def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" - invalid = [] + out_set = instance.data["rig_sets"].get("out_SET") + if not out_set: + return [] - out_set = next(x for x in instance if x.endswith("out_SET")) + invalid = [] members = cmds.sets(out_set, query=True) shapes = cmds.ls(members, dag=True, diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index cbc750bace..24fb36eb8b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,10 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = next(x for x in instance if "out_SET" in x) + out_set = instance.data["rig_sets"].get("out_SET") + if not out_set: + instance.data["mismatched_output_ids"] = invalid + return invalid instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True) instance_nodes = cmds.ls(instance_nodes, long=True) From a492addc54691fd23ce462cd06284809a3edeaa0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 15:55:28 +0200 Subject: [PATCH 352/429] Reduce artist-facing logs for component integration for Ftrack + tweak "Comment is not set" log also for Kitsu to debug level --- .../ftrack/plugins/publish/integrate_ftrack_api.py | 12 ++++++------ .../plugins/publish/integrate_ftrack_description.py | 2 +- .../ftrack/plugins/publish/integrate_ftrack_note.py | 6 +++--- .../kitsu/plugins/publish/integrate_kitsu_note.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 4d474fab10..858c0bb2d6 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -27,8 +27,8 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): def process(self, instance): component_list = instance.data.get("ftrackComponentsList") if not component_list: - self.log.info( - "Instance don't have components to integrate to Ftrack." + self.log.debug( + "Instance doesn't have components to integrate to Ftrack." " Skipping." ) return @@ -37,7 +37,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): task_entity, parent_entity = self.get_instance_entities( instance, context) if parent_entity is None: - self.log.info(( + self.log.debug(( "Skipping ftrack integration. Instance \"{}\" does not" " have specified ftrack entities." ).format(str(instance))) @@ -323,7 +323,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): "type_id": asset_type_id, "context_id": parent_id } - self.log.info("Created new Asset with data: {}.".format(asset_data)) + self.log.debug("Created new Asset with data: {}.".format(asset_data)) session.create("Asset", asset_data) session.commit() return self._query_asset(session, asset_name, asset_type_id, parent_id) @@ -384,7 +384,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if comment: new_asset_version_data["comment"] = comment - self.log.info("Created new AssetVersion with data {}".format( + self.log.debug("Created new AssetVersion with data {}".format( new_asset_version_data )) session.create("AssetVersion", new_asset_version_data) @@ -555,7 +555,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): location=location ) data["component"] = component_entity - self.log.info( + self.log.debug( ( "Created new Component with path: {0}, data: {1}," " metadata: {2}, location: {3}" diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py index 6ed02bc8b6..ceaff8ff54 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py @@ -40,7 +40,7 @@ class IntegrateFtrackDescription(pyblish.api.InstancePlugin): comment = instance.data["comment"] if not comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is set to `{}`".format(comment)) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py index 6e82897d89..10b7932cdf 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py @@ -47,7 +47,7 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): app_label = context.data["appLabel"] comment = instance.data["comment"] if not comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is set to `{}`".format(comment)) @@ -127,14 +127,14 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): note_text = StringTemplate.format_template(template, format_data) if not note_text.solved: - self.log.warning(( + self.log.debug(( "Note template require more keys then can be provided." "\nTemplate: {}\nMissing values for keys:{}\nData: {}" ).format(template, note_text.missing_keys, format_data)) continue if not note_text: - self.log.info(( + self.log.debug(( "Note for AssetVersion {} would be empty. Skipping." "\nTemplate: {}\nData: {}" ).format(asset_version["id"], template, format_data)) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 6e5dd056f3..b66e1f01e0 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -121,7 +121,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): publish_comment = self.format_publish_comment(instance) if not publish_comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is `{}`".format(publish_comment)) From 3a02964af5e4c2c9aac5da4770e59aeef913f98a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 15:58:28 +0200 Subject: [PATCH 353/429] Do not show debug log about ffmpeg probe in artist-facing report --- openpype/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 2bae28786e..6e323f55c1 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -724,7 +724,7 @@ def get_ffprobe_data(path_to_file, logger=None): """ if not logger: logger = logging.getLogger(__name__) - logger.info( + logger.debug( "Getting information about input \"{}\".".format(path_to_file) ) ffprobe_args = get_ffmpeg_tool_args("ffprobe") From 2919d241da617229efa47c9ae52854633e0100c4 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 19:30:10 +0300 Subject: [PATCH 354/429] move import inside --- .../hosts/houdini/plugins/create/create_review.py | 12 ++++-------- .../plugins/publish/validate_review_colorspace.py | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index c087c54f6c..60c34a358b 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -2,7 +2,6 @@ """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou @@ -87,7 +86,8 @@ class CreateReview(plugin.HoudiniCreator): # Set OCIO Colorspace to the default output colorspace # if there's OCIO - self.set_colorcorrect_to_default_view_space(instance_node) + if os.getenv("OCIO"): + self.set_colorcorrect_to_default_view_space(instance_node) to_lock = ["id", "family"] @@ -134,13 +134,9 @@ class CreateReview(plugin.HoudiniCreator): def set_colorcorrect_to_default_view_space(self, instance_node): """Set ociocolorspace to the default output space.""" + from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa - if os.getenv("OCIO") is None: - # No OCIO, skip setting ociocolorspace - return - - # if there's OCIO then set Color Correction parameter - # to OpenColorIO + # set Color Correction parameter to OpenColorIO instance_node.setParms({"colorcorrect": 2}) # Get default view space for ociocolorspace parm. diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 61c3a755d0..03ecd1b052 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -6,7 +6,6 @@ from openpype.pipeline import ( ) from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou @@ -69,6 +68,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, It is a helper action more than a repair action, used to set colorspace on opengl node to the default view. """ + from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa rop_node = hou.node(instance.data["instance_node"]) From 9d2fc2fca4622e8c91ba4539034a03f67b57fc40 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 22:30:03 +0300 Subject: [PATCH 355/429] Jakub comments --- openpype/hosts/houdini/api/lib.py | 12 +----------- .../plugins/inventory/set_camera_resolution.py | 6 +++--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index f83519ddb8..eff98c05f1 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -651,16 +651,6 @@ def get_color_management_preferences(): } -def get_current_asset_doc(): - """Get asset document of the current asset. """ - - project_name = get_current_project_name() - asset_name = get_current_asset_name() - asset_doc = get_asset_by_name(project_name, asset_name) - - return asset_doc - - def get_resolution_from_doc(doc): """Get resolution from the given asset document. """ @@ -683,7 +673,7 @@ def set_camera_resolution(camera, asset_doc=None): """Apply resolution to camera from asset document of the publish""" if not asset_doc: - asset_doc = get_current_asset_doc() + asset_doc = get_current_project_asset() resolution = get_resolution_from_doc(asset_doc) diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index 5dd94232b8..97b94e66aa 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -3,7 +3,7 @@ from openpype.hosts.houdini.api.lib import ( get_camera_from_container, set_camera_resolution ) - +from openpype.pipeline.context_tools import get_current_project_asset class SetCameraResolution(InventoryAction): @@ -13,13 +13,13 @@ class SetCameraResolution(InventoryAction): @staticmethod def is_compatible(container): - print(container) return ( container.get("loader") == "CameraLoader" ) def process(self, containers): + asset_doc = get_current_project_asset() for container in containers: node = container["node"] camera = get_camera_from_container(node) - set_camera_resolution(camera) + set_camera_resolution(camera, asset_doc) From d38d25308dc393ced6504e86e925d8c821c92868 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 22:31:48 +0300 Subject: [PATCH 356/429] resolve hound --- .../hosts/houdini/plugins/inventory/set_camera_resolution.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index 97b94e66aa..18ececb019 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -5,6 +5,7 @@ from openpype.hosts.houdini.api.lib import ( ) from openpype.pipeline.context_tools import get_current_project_asset + class SetCameraResolution(InventoryAction): label = "Set Camera Resolution" From d2f63f0cd489b490111c4fdba4916bfb2c1e2ad0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 23:27:30 +0200 Subject: [PATCH 357/429] Fix look assigner showing no meshes if 'not found' representations are present --- openpype/hosts/maya/tools/mayalookassigner/commands.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/tools/mayalookassigner/commands.py b/openpype/hosts/maya/tools/mayalookassigner/commands.py index a1290aa68d..5cc4f84931 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/commands.py +++ b/openpype/hosts/maya/tools/mayalookassigner/commands.py @@ -138,8 +138,13 @@ def create_items_from_nodes(nodes): asset_doc = asset_docs_by_id.get(asset_id) # Skip if asset id is not found if not asset_doc: - log.warning("Id not found in the database, skipping '%s'." % _id) - log.warning("Nodes: %s" % id_nodes) + log.warning( + "Id found on {num} nodes for which no asset is found database," + " skipping '{asset_id}'".format( + num=len(nodes), + asset_id=asset_id + ) + ) continue # Collect available look subsets for this asset From 572d6e3ab52d5d398a9a25fd4405cad4fecea796 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 8 Sep 2023 12:44:14 +0300 Subject: [PATCH 358/429] update ayon settings --- .../houdini/server/settings/publish_plugins.py | 12 ++++++------ server_addon/houdini/server/version.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 528e847fce..6ceff028a5 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -167,14 +167,14 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Latest Containers.") - ValidateSubsetName: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateSubsetName: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Subset Name.") - ValidateMeshIsStatic: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateMeshIsStatic: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Mesh is Static.") - ValidateUnrealStaticMeshName: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateUnrealStaticMeshName: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Unreal Static Mesh Name.") diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 076d16a50d21ceb1edfbc806b20554a971c01b40 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:45:00 +0200 Subject: [PATCH 359/429] Workfiles tool: Refactor workfiles tool (for AYON) (#5550) * ayon workfiles tool initial commit * separated models into smaller files * workfile can be listed and opened * added browse logic * added TODO for helper functions * modified abstract controller * implemented required methods * base of save dialog * added project settings to controller * set context of side panel on init * implemented save as dialog * cleanup expected selection * unify controller variable name * base of published workfiles * working published workfile copy * added more missing features from workfiles tool * Changed size policy of buttons to fill space vertically * added overlay messages * moved objects to abstraction * moved 'window.py' to widgets * small modifications in widgets * get_workfile_info returns object * filled docstrings in abstractions * finishing touches * backwards compatible work with host * close window on successfull open * remove indentation completelly * added style for overlay label * added handling of invalid host in controller * added overlay with message if host is not valid * added missing feature of disabled save * use ayon_workfiles in ayon mode * cleanup * hound fixes * use asset doc for 'change_current_context' * added duplication action * removed unused attributes and methods * refresh workarea view on save as finished * support host integrations without 'HostBase' * fix 'filepath' fill * reset item cache on save * do not handle filepath in prepare workfile * rename '_create_workfile_doc' > '_create_workfile_info_entity' * fill comment before formatting * fix column count by not calling 'clear' * more explicit name of method * use 'setHeaderData' to define header labels * mimic changes from workarea widget in published widget --- openpype/style/style.css | 4 + openpype/tools/ayon_workfiles/__init__.py | 0 openpype/tools/ayon_workfiles/abstract.py | 984 ++++++++++++++++++ openpype/tools/ayon_workfiles/control.py | 642 ++++++++++++ .../tools/ayon_workfiles/models/__init__.py | 10 + .../tools/ayon_workfiles/models/hierarchy.py | 225 ++++ .../tools/ayon_workfiles/models/selection.py | 91 ++ .../tools/ayon_workfiles/models/workfiles.py | 711 +++++++++++++ .../tools/ayon_workfiles/widgets/__init__.py | 6 + .../tools/ayon_workfiles/widgets/constants.py | 7 + .../ayon_workfiles/widgets/files_widget.py | 398 +++++++ .../widgets/files_widget_published.py | 378 +++++++ .../widgets/files_widget_workarea.py | 380 +++++++ .../ayon_workfiles/widgets/folders_widget.py | 324 ++++++ .../ayon_workfiles/widgets/save_as_dialog.py | 351 +++++++ .../ayon_workfiles/widgets/side_panel.py | 163 +++ .../ayon_workfiles/widgets/tasks_widget.py | 420 ++++++++ .../tools/ayon_workfiles/widgets/utils.py | 94 ++ .../tools/ayon_workfiles/widgets/window.py | 400 +++++++ openpype/tools/utils/host_tools.py | 30 +- 20 files changed, 5610 insertions(+), 8 deletions(-) create mode 100644 openpype/tools/ayon_workfiles/__init__.py create mode 100644 openpype/tools/ayon_workfiles/abstract.py create mode 100644 openpype/tools/ayon_workfiles/control.py create mode 100644 openpype/tools/ayon_workfiles/models/__init__.py create mode 100644 openpype/tools/ayon_workfiles/models/hierarchy.py create mode 100644 openpype/tools/ayon_workfiles/models/selection.py create mode 100644 openpype/tools/ayon_workfiles/models/workfiles.py create mode 100644 openpype/tools/ayon_workfiles/widgets/__init__.py create mode 100644 openpype/tools/ayon_workfiles/widgets/constants.py create mode 100644 openpype/tools/ayon_workfiles/widgets/files_widget.py create mode 100644 openpype/tools/ayon_workfiles/widgets/files_widget_published.py create mode 100644 openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py create mode 100644 openpype/tools/ayon_workfiles/widgets/folders_widget.py create mode 100644 openpype/tools/ayon_workfiles/widgets/save_as_dialog.py create mode 100644 openpype/tools/ayon_workfiles/widgets/side_panel.py create mode 100644 openpype/tools/ayon_workfiles/widgets/tasks_widget.py create mode 100644 openpype/tools/ayon_workfiles/widgets/utils.py create mode 100644 openpype/tools/ayon_workfiles/widgets/window.py diff --git a/openpype/style/style.css b/openpype/style/style.css index 5ce55aa658..ca368f84f8 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1427,6 +1427,10 @@ CreateNextPageOverlay { background: rgba(0, 0, 0, 127); } +#OverlayFrameLabel { + font-size: 15pt; +} + #BreadcrumbsPathInput { padding: 2px; font-size: 9pt; diff --git a/openpype/tools/ayon_workfiles/__init__.py b/openpype/tools/ayon_workfiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py new file mode 100644 index 0000000000..e30a2c2499 --- /dev/null +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -0,0 +1,984 @@ +import os +from abc import ABCMeta, abstractmethod + +import six +from openpype.style import get_default_entity_icon_color + + +class WorkfileInfo: + """Information about workarea file with possible additional from database. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Filepath. + filesize (int): File size. + creation_time (int): Creation time (timestamp). + modification_time (int): Modification time (timestamp). + note (str): Note. + """ + + def __init__( + self, + folder_id, + task_id, + filepath, + filesize, + creation_time, + modification_time, + note, + ): + self.folder_id = folder_id + self.task_id = task_id + self.filepath = filepath + self.filesize = filesize + self.creation_time = creation_time + self.modification_time = modification_time + self.note = note + + def to_data(self): + """Converts WorkfileInfo item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "folder_id": self.folder_id, + "task_id": self.task_id, + "filepath": self.filepath, + "filesize": self.filesize, + "creation_time": self.creation_time, + "modification_time": self.modification_time, + "note": self.note, + } + + @classmethod + def from_data(cls, data): + """Re-creates WorkfileInfo item from data. + + Args: + data (dict[str, Any]): Workfile info item data. + + Returns: + WorkfileInfo: Workfile info item. + """ + + return cls(**data) + + +class FolderItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + entity_id (str): Folder id. + parent_id (Union[str, None]): Parent folder id. If 'None' then project + is parent. + name (str): Name of folder. + label (str): Folder label. + icon_name (str): Name of icon from font awesome. + icon_color (str): Hex color string that will be used for icon. + """ + + def __init__( + self, entity_id, parent_id, name, label, icon_name, icon_color + ): + self.entity_id = entity_id + self.parent_id = parent_id + self.name = name + self.icon_name = icon_name or "fa.folder" + self.icon_color = icon_color or get_default_entity_icon_color() + self.label = label or name + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "entity_id": self.entity_id, + "parent_id": self.parent_id, + "name": self.name, + "label": self.label, + "icon_name": self.icon_name, + "icon_color": self.icon_color, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +class TaskItem: + """Task item representing task entity on a server. + + Task is child of a folder. + + Task item has label that is used for display in UI. The label is by + default using task name and type. + + Args: + task_id (str): Task id. + name (str): Name of task. + task_type (str): Type of task. + parent_id (str): Parent folder id. + icon_name (str): Name of icon from font awesome. + icon_color (str): Hex color string that will be used for icon. + """ + + def __init__( + self, task_id, name, task_type, parent_id, icon_name, icon_color + ): + self.task_id = task_id + self.name = name + self.task_type = task_type + self.parent_id = parent_id + self.icon_name = icon_name or "fa.male" + self.icon_color = icon_color or get_default_entity_icon_color() + self._label = None + + @property + def id(self): + """Alias for task_id. + + Returns: + str: Task id. + """ + + return self.task_id + + @property + def label(self): + """Label of task item for UI. + + Returns: + str: Label of task item. + """ + + if self._label is None: + self._label = "{} ({})".format(self.name, self.task_type) + return self._label + + def to_data(self): + """Converts task item to data. + + Returns: + dict[str, Any]: Task item data. + """ + + return { + "task_id": self.task_id, + "name": self.name, + "parent_id": self.parent_id, + "task_type": self.task_type, + "icon_name": self.icon_name, + "icon_color": self.icon_color, + } + + @classmethod + def from_data(cls, data): + """Re-create task item from data. + + Args: + data (dict[str, Any]): Task item data. + + Returns: + TaskItem: Task item. + """ + + return cls(**data) + + +class FileItem: + """File item that represents a file. + + Can be used for both Workarea and Published workfile. Workarea file + will always exist on disk which is not the case for Published workfile. + + Args: + dirpath (str): Directory path of file. + filename (str): Filename. + modified (float): Modified timestamp. + representation_id (Optional[str]): Representation id of published + workfile. + filepath (Optional[str]): Prepared filepath. + exists (Optional[bool]): If file exists on disk. + """ + + def __init__( + self, + dirpath, + filename, + modified, + representation_id=None, + filepath=None, + exists=None + ): + self.filename = filename + self.dirpath = dirpath + self.modified = modified + self.representation_id = representation_id + self._filepath = filepath + self._exists = exists + + @property + def filepath(self): + """Filepath of file. + + Returns: + str: Full path to a file. + """ + + if self._filepath is None: + self._filepath = os.path.join(self.dirpath, self.filename) + return self._filepath + + @property + def exists(self): + """File is available. + + Returns: + bool: If file exists on disk. + """ + + if self._exists is None: + self._exists = os.path.exists(self.filepath) + return self._exists + + def to_data(self): + """Converts file item to data. + + Returns: + dict[str, Any]: File item data. + """ + + return { + "filename": self.filename, + "dirpath": self.dirpath, + "modified": self.modified, + "representation_id": self.representation_id, + "filepath": self.filepath, + "exists": self.exists, + } + + @classmethod + def from_data(cls, data): + """Re-creates file item from data. + + Args: + data (dict[str, Any]): File item data. + + Returns: + FileItem: File item. + """ + + required_keys = { + "filename", + "dirpath", + "modified", + "representation_id" + } + missing_keys = required_keys - set(data.keys()) + if missing_keys: + raise KeyError("Missing keys: {}".format(missing_keys)) + + return cls(**{ + key: data[key] + for key in required_keys + }) + + +class WorkareaFilepathResult: + """Result of workarea file formatting. + + Args: + root (str): Root path of workarea. + filename (str): Filename. + exists (bool): True if file exists. + filepath (str): Filepath. If not provided it will be constructed + from root and filename. + """ + + def __init__(self, root, filename, exists, filepath=None): + if not filepath and root and filename: + filepath = os.path.join(root, filename) + self.root = root + self.filename = filename + self.exists = exists + self.filepath = filepath + + +@six.add_metaclass(ABCMeta) +class AbstractWorkfilesCommon(object): + @abstractmethod + def is_host_valid(self): + """Host is valid for workfiles tool work. + + Returns: + bool: True if host is valid. + """ + + pass + + @abstractmethod + def get_workfile_extensions(self): + """Get possible workfile extensions. + + Defined by host implementation. + + Returns: + Iterable[str]: List of extensions. + """ + + pass + + @abstractmethod + def is_save_enabled(self): + """Is workfile save enabled. + + Returns: + bool: True if save is enabled. + """ + + pass + + @abstractmethod + def set_save_enabled(self, enabled): + """Enable or disabled workfile save. + + Args: + enabled (bool): Enable save workfile when True. + """ + + pass + + +class AbstractWorkfilesBackend(AbstractWorkfilesCommon): + # Current context + @abstractmethod + def get_host_name(self): + """Name of host. + + Returns: + str: Name of host. + """ + pass + + @abstractmethod + def get_current_project_name(self): + """Project name from current context of host. + + Returns: + str: Name of project. + """ + + pass + + @abstractmethod + def get_current_folder_id(self): + """Folder id from current context of host. + + Returns: + Union[str, None]: Folder id or None if host does not have + any context. + """ + + pass + + @abstractmethod + def get_current_task_name(self): + """Task name from current context of host. + + Returns: + Union[str, None]: Task name or None if host does not have + any context. + """ + + pass + + @abstractmethod + def get_current_workfile(self): + """Current workfile from current context of host. + + Returns: + Union[str, None]: Path to workfile or None if host does + not have opened specific file. + """ + + pass + + @property + @abstractmethod + def project_anatomy(self): + """Project anatomy for current project. + + Returns: + Anatomy: Project anatomy. + """ + + pass + + @property + @abstractmethod + def project_settings(self): + """Project settings for current project. + + Returns: + dict[str, Any]: Project settings. + """ + + pass + + @abstractmethod + def get_folder_entity(self, folder_id): + """Get folder entity by id. + + Args: + folder_id (str): Folder id. + + Returns: + dict[str, Any]: Folder entity data. + """ + + pass + + @abstractmethod + def get_task_entity(self, task_id): + """Get task entity by id. + + Args: + task_id (str): Task id. + + Returns: + dict[str, Any]: Task entity data. + """ + + pass + + def emit_event(self, topic, data=None, source=None): + """Emit event. + + Args: + topic (str): Event topic used for callbacks filtering. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + +class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): + """UI controller abstraction that is used for workfiles tool frontend. + + Abstraction to provide data for UI and to handle UI events. + + Provide access to abstract backend data, like folders and tasks. Cares + about handling of selection, keep information about current UI selection + and have ability to tell what selection should UI show. + + Selection is separated into 2 parts, first is what UI elements tell + about selection, and second is what UI should show as selected. + """ + + @abstractmethod + def register_event_callback(self, topic, callback): + """Register event callback. + + Listen for events with given topic. + + Args: + topic (str): Name of topic. + callback (Callable): Callback that will be called when event + is triggered. + """ + + pass + + # Host information + @abstractmethod + def get_workfile_extensions(self): + """Each host can define extensions that can be used for workfile. + + Returns: + List[str]: File extensions that can be used as workfile for + current host. + """ + + pass + + # Selection information + @abstractmethod + def get_selected_folder_id(self): + """Currently selected folder id. + + Returns: + Union[str, None]: Folder id or None if no folder is selected. + """ + + pass + + @abstractmethod + def set_selected_folder(self, folder_id): + """Change selected folder. + + This deselects currently selected task. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + """ + + pass + + @abstractmethod + def get_selected_task_id(self): + """Currently selected task id. + + Returns: + Union[str, None]: Task id or None if no folder is selected. + """ + + pass + + @abstractmethod + def get_selected_task_name(self): + """Currently selected task name. + + Returns: + Union[str, None]: Task name or None if no folder is selected. + """ + + pass + + @abstractmethod + def set_selected_task(self, folder_id, task_id, task_name): + """Change selected task. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + task_id (Union[str, None]): Task id or None if no task + is selected. + task_name (Union[str, None]): Task name or None if no task + is selected. + """ + + pass + + @abstractmethod + def get_selected_workfile_path(self): + """Currently selected workarea workile. + + Returns: + Union[str, None]: Selected workfile path. + """ + + pass + + @abstractmethod + def set_selected_workfile_path(self, path): + """Change selected workfile path. + + Args: + path (Union[str, None]): Selected workfile path. + """ + + pass + + @abstractmethod + def get_selected_representation_id(self): + """Currently selected workfile representation id. + + Returns: + Union[str, None]: Representation id or None if no representation + is selected. + """ + + pass + + @abstractmethod + def set_selected_representation_id(self, representation_id): + """Change selected representation. + + Args: + representation_id (Union[str, None]): Selected workfile + representation id. + """ + + pass + + def get_selected_context(self): + """Obtain selected context. + + Returns: + dict[str, Union[str, None]]: Selected context. + """ + + return { + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + "workfile_path": self.get_selected_workfile_path(), + "representation_id": self.get_selected_representation_id(), + } + + # Expected selection + # - expected selection is used to restore selection after refresh + # or when current context should be used + @abstractmethod + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + """Define what should be selected in UI. + + Expected selection provide a way to define/change selection of + sequential UI elements. For example, if folder and task should be + selected a task element should wait until folder element has selected + folder. + + Triggers 'expected_selection.changed' event. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + workfile_name (Optional[str]): Workfile name. Used for workarea + files UI element. + representation_id (Optional[str]): Representation id. Used for + published filed UI element. + """ + + pass + + @abstractmethod + def get_expected_selection_data(self): + """Data of expected selection. + + TODOs: + Return defined object instead of dict. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + """Expected folder was selected in UI. + + Args: + folder_id (str): Folder id which was selected. + """ + + pass + + @abstractmethod + def expected_task_selected(self, folder_id, task_name): + """Expected task was selected in UI. + + Args: + folder_id (str): Folder id under which task is. + task_name (str): Task name which was selected. + """ + + pass + + @abstractmethod + def expected_representation_selected(self, representation_id): + """Expected representation was selected in UI. + + Args: + representation_id (str): Representation id which was selected. + """ + + pass + + @abstractmethod + def expected_workfile_selected(self, workfile_path): + """Expected workfile was selected in UI. + + Args: + workfile_path (str): Workfile path which was selected. + """ + + pass + + @abstractmethod + def go_to_current_context(self): + """Set expected selection to current context.""" + + pass + + # Model functions + @abstractmethod + def get_folder_items(self, sender): + """Folder items to visualize project hierarchy. + + This function may trigger events 'folders.refresh.started' and + 'folders.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of folder items in UI elements. + + Args: + sender (str): Who requested folder items. + + Returns: + list[FolderItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_task_items(self, folder_id, sender): + """Task items. + + This function may trigger events 'tasks.refresh.started' and + 'tasks.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of task items in UI elements. + + Args: + folder_id (str): Folder ID for which are tasks requested. + sender (str): Who requested folder items. + + Returns: + list[TaskItem]: Minimum possible information needed + for visualisation of tasks. + """ + + pass + + @abstractmethod + def has_unsaved_changes(self): + """Has host unsaved change in currently running session. + + Returns: + bool: Has unsaved changes. + """ + + pass + + @abstractmethod + def get_workarea_dir_by_context(self, folder_id, task_id): + """Get workarea directory by context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + str: Workarea directory. + """ + + pass + + @abstractmethod + def get_workarea_file_items(self, folder_id, task_id): + """Get workarea file items. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + list[FileItem]: List of workarea file items. + """ + + pass + + @abstractmethod + def get_workarea_save_as_data(self, folder_id, task_id): + """Prepare data for Save As operation. + + Todos: + Return defined object instead of dict. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + dict[str, Any]: Data for Save As operation. + """ + + pass + + @abstractmethod + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + """Calculate workfile path for passed context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + extension (str): File extension. + use_last_version (bool): Use last version. + version (int): Version used if 'use_last_version' if 'False'. + comment (str): User's comment (subversion). + + Returns: + WorkareaFilepathResult: Result of the operation. + """ + + pass + + @abstractmethod + def get_published_file_items(self, folder_id, task_id): + """Get published file items. + + Args: + folder_id (str): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[FileItem]: List of published file items. + """ + + pass + + @abstractmethod + def get_workfile_info(self, folder_id, task_id, filepath): + """Workfile info from database. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + + Returns: + Union[WorkfileInfo, None]: Workfile info or None if was passed + invalid context. + """ + + pass + + @abstractmethod + def save_workfile_info(self, folder_id, task_id, filepath, note): + """Save workfile info to database. + + At this moment the only information which can be saved about + workfile is 'note'. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + note (str): Note. + """ + + pass + + # General commands + @abstractmethod + def refresh(self): + """Refresh everything, models, ui etc. + + Triggers 'controller.refresh.started' event at the beginning and + 'controller.refresh.finished' at the end. + """ + + pass + + # Controller actions + @abstractmethod + def open_workfile(self, filepath): + """Open a workfile. + + Args: + filepath (str): Workfile path. + """ + + pass + + @abstractmethod + def save_current_workfile(self): + """Save state of current workfile.""" + + pass + + @abstractmethod + def save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + """Save current state of workfile to workarea. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + workdir (str): Workarea directory. + filename (str): Workarea filename. + template_key (str): Template key used to get the workdir + and filename. + """ + + pass + + @abstractmethod + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + """Action to copy published workfile representation to workarea. + + Triggers 'copy_representation.started' event on start and + 'copy_representation.finished' event with '{"failed": bool}'. + + Args: + representation_id (str): Representation id. + representation_filepath (str): Path to representation file. + folder_id (str): Folder id. + task_id (str): Task id. + workdir (str): Workarea directory. + filename (str): Workarea filename. + template_key (str): Template key. + """ + + pass + + @abstractmethod + def duplicate_workfile(self, src_filepath, workdir, filename): + """Duplicate workfile. + + Workfiles is not opened when done. + + Args: + src_filepath (str): Source workfile path. + workdir (str): Destination workdir. + filename (str): Destination filename. + """ + + pass diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py new file mode 100644 index 0000000000..fc8819bff3 --- /dev/null +++ b/openpype/tools/ayon_workfiles/control.py @@ -0,0 +1,642 @@ +import os +import shutil + +import ayon_api + +from openpype.client import get_asset_by_id +from openpype.host import IWorkfileHost +from openpype.lib import Logger, emit_event +from openpype.lib.events import QueuedEventSystem +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy, registered_host +from openpype.pipeline.context_tools import ( + change_current_context, + get_current_host_name, + get_global_context, +) +from openpype.pipeline.workfile import create_workdir_extra_folders + +from .abstract import ( + AbstractWorkfilesFrontend, + AbstractWorkfilesBackend, +) +from .models import SelectionModel, EntitiesModel, WorkfilesModel + + +class ExpectedSelection: + def __init__(self): + self._folder_id = None + self._task_name = None + self._workfile_name = None + self._representation_id = None + self._folder_selected = True + self._task_selected = True + self._workfile_name_selected = True + self._representation_id_selected = True + + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + self._folder_id = folder_id + self._task_name = task_name + self._workfile_name = workfile_name + self._representation_id = representation_id + self._folder_selected = False + self._task_selected = False + self._workfile_name_selected = workfile_name is None + self._representation_id_selected = representation_id is None + + def get_expected_selection_data(self): + return { + "folder_id": self._folder_id, + "task_name": self._task_name, + "workfile_name": self._workfile_name, + "representation_id": self._representation_id, + "folder_selected": self._folder_selected, + "task_selected": self._task_selected, + "workfile_name_selected": self._workfile_name_selected, + "representation_id_selected": self._representation_id_selected, + } + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def is_expected_task_selected(self, folder_id, task_name): + if not self.is_expected_folder_selected(folder_id): + return False + return task_name == self._task_name and self._task_selected + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + return True + + def expected_task_selected(self, folder_id, task_name): + if not self.is_expected_folder_selected(folder_id): + return False + + if task_name != self._task_name: + return False + + self._task_selected = True + return True + + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + if not self.is_expected_task_selected(folder_id, task_name): + return False + + if workfile_name != self._workfile_name: + return False + self._workfile_name_selected = True + return True + + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + if not self.is_expected_task_selected(folder_id, task_name): + return False + if representation_id != self._representation_id: + return False + self._representation_id_selected = True + return True + + +class BaseWorkfileController( + AbstractWorkfilesFrontend, AbstractWorkfilesBackend +): + def __init__(self, host=None): + if host is None: + host = registered_host() + + host_is_valid = False + if host is not None: + missing_methods = ( + IWorkfileHost.get_missing_workfile_methods(host) + ) + host_is_valid = len(missing_methods) == 0 + + self._host = host + self._host_is_valid = host_is_valid + + self._project_anatomy = None + self._project_settings = None + self._event_system = None + self._log = None + + self._current_project_name = None + self._current_folder_name = None + self._current_folder_id = None + self._current_task_name = None + self._save_is_enabled = True + + # Expected selected folder and task + self._expected_selection = self._create_expected_selection_obj() + + self._selection_model = self._create_selection_model() + self._entities_model = self._create_entities_model() + self._workfiles_model = self._create_workfiles_model() + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger("WorkfilesUI") + return self._log + + def is_host_valid(self): + return self._host_is_valid + + def _create_expected_selection_obj(self): + return ExpectedSelection() + + def _create_selection_model(self): + return SelectionModel(self) + + def _create_entities_model(self): + return EntitiesModel(self) + + def _create_workfiles_model(self): + return WorkfilesModel(self) + + @property + def event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + # ---------------------------------------------------- + # Implementation of methods required for backend logic + # ---------------------------------------------------- + @property + def project_settings(self): + if self._project_settings is None: + self._project_settings = get_project_settings( + self.get_current_project_name()) + return self._project_settings + + @property + def project_anatomy(self): + if self._project_anatomy is None: + self._project_anatomy = Anatomy(self.get_current_project_name()) + return self._project_anatomy + + def get_folder_entity(self, folder_id): + return self._entities_model.get_folder_entity(folder_id) + + def get_task_entity(self, task_id): + return self._entities_model.get_task_entity(task_id) + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self.event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self.event_system.add_callback(topic, callback) + + def is_save_enabled(self): + """Is workfile save enabled. + + Returns: + bool: True if save is enabled. + """ + + return self._save_is_enabled + + def set_save_enabled(self, enabled): + """Enable or disabled workfile save. + + Args: + enabled (bool): Enable save workfile when True. + """ + + if self._save_is_enabled == enabled: + return + + self._save_is_enabled = enabled + self._emit_event( + "workfile_save_enable.changed", + {"enabled": enabled} + ) + + # Host information + def get_workfile_extensions(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.get_workfile_extensions() + return host.file_extensions() + + def has_unsaved_changes(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.workfile_has_unsaved_changes() + return host.has_unsaved_changes() + + # Current context + def get_host_name(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.name + return get_current_host_name() + + def _get_host_current_context(self): + if hasattr(self._host, "get_current_context"): + return self._host.get_current_context() + return get_global_context() + + def get_current_project_name(self): + return self._current_project_name + + def get_current_folder_id(self): + return self._current_folder_id + + def get_current_task_name(self): + return self._current_task_name + + def get_current_workfile(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.get_current_workfile() + return host.current_file() + + # Selection information + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def set_selected_task(self, folder_id, task_id, task_name): + return self._selection_model.set_selected_task( + folder_id, task_id, task_name) + + def get_selected_workfile_path(self): + return self._selection_model.get_selected_workfile_path() + + def set_selected_workfile_path(self, path): + self._selection_model.set_selected_workfile_path(path) + + def get_selected_representation_id(self): + return self._selection_model.get_selected_representation_id() + + def set_selected_representation_id(self, representation_id): + self._selection_model.set_selected_representation_id( + representation_id) + + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + self._expected_selection.set_expected_selection( + folder_id, task_name, workfile_name, representation_id + ) + self._trigger_expected_selection_changed() + + def expected_folder_selected(self, folder_id): + if self._expected_selection.expected_folder_selected(folder_id): + self._trigger_expected_selection_changed() + + def expected_task_selected(self, folder_id, task_name): + if self._expected_selection.expected_task_selected( + folder_id, task_name + ): + self._trigger_expected_selection_changed() + + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + if self._expected_selection.expected_workfile_selected( + folder_id, task_name, workfile_name + ): + self._trigger_expected_selection_changed() + + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + if self._expected_selection.expected_representation_selected( + folder_id, task_name, representation_id + ): + self._trigger_expected_selection_changed() + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def go_to_current_context(self): + self.set_expected_selection( + self._current_folder_id, self._current_task_name + ) + + # Model functions + def get_folder_items(self, sender): + return self._entities_model.get_folder_items(sender) + + def get_task_items(self, folder_id, sender): + return self._entities_model.get_tasks_items(folder_id, sender) + + def get_workarea_dir_by_context(self, folder_id, task_id): + return self._workfiles_model.get_workarea_dir_by_context( + folder_id, task_id) + + def get_workarea_file_items(self, folder_id, task_id): + return self._workfiles_model.get_workarea_file_items( + folder_id, task_id) + + def get_workarea_save_as_data(self, folder_id, task_id): + return self._workfiles_model.get_workarea_save_as_data( + folder_id, task_id) + + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + return self._workfiles_model.fill_workarea_filepath( + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ) + + def get_published_file_items(self, folder_id, task_id): + task_name = None + if task_id: + task = self.get_task_entity(task_id) + task_name = task.get("name") + + return self._workfiles_model.get_published_file_items( + folder_id, task_name) + + def get_workfile_info(self, folder_id, task_id, filepath): + return self._workfiles_model.get_workfile_info( + folder_id, task_id, filepath + ) + + def save_workfile_info(self, folder_id, task_id, filepath, note): + self._workfiles_model.save_workfile_info( + folder_id, task_id, filepath, note + ) + + def refresh(self): + if not self._host_is_valid: + self._emit_event("controller.refresh.started") + self._emit_event("controller.refresh.finished") + return + expected_folder_id = self.get_selected_folder_id() + expected_task_name = self.get_selected_task_name() + + self._emit_event("controller.refresh.started") + + context = self._get_host_current_context() + + project_name = context["project_name"] + folder_name = context["asset_name"] + task_name = context["task_name"] + folder_id = None + if folder_name: + folder = ayon_api.get_folder_by_name(project_name, folder_name) + if folder: + folder_id = folder["id"] + + self._project_settings = None + self._project_anatomy = None + + self._current_project_name = project_name + self._current_folder_name = folder_name + self._current_folder_id = folder_id + self._current_task_name = task_name + + if not expected_folder_id: + expected_folder_id = folder_id + expected_task_name = task_name + + self._expected_selection.set_expected_selection( + expected_folder_id, expected_task_name + ) + + self._entities_model.refresh() + + self._emit_event("controller.refresh.finished") + + # Controller actions + def open_workfile(self, filepath): + self._emit_event("open_workfile.started") + + failed = False + try: + self._host_open_workfile(filepath) + + except Exception: + failed = True + self.log.warning("Open of workfile failed", exc_info=True) + + self._emit_event( + "open_workfile.finished", + {"failed": failed}, + ) + + def save_current_workfile(self): + current_file = self.get_current_workfile() + self._host_save_workfile(current_file) + + def save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + self._emit_event("save_as.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + ) + except Exception: + failed = True + self.log.warning("Save as failed", exc_info=True) + + self._emit_event( + "save_as.finished", + {"failed": failed}, + ) + + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + self._emit_event("copy_representation.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + ) + except Exception: + failed = True + self.log.warning( + "Copy of workfile representation failed", exc_info=True + ) + + self._emit_event( + "copy_representation.finished", + {"failed": failed}, + ) + + def duplicate_workfile(self, src_filepath, workdir, filename): + self._emit_event("workfile_duplicate.started") + + failed = False + try: + dst_filepath = os.path.join(workdir, filename) + shutil.copy(src_filepath, dst_filepath) + except Exception: + failed = True + self.log.warning("Duplication of workfile failed", exc_info=True) + + self._emit_event( + "workfile_duplicate.finished", + {"failed": failed}, + ) + + # Helper host methods that resolve 'IWorkfileHost' interface + def _host_open_workfile(self, filepath): + host = self._host + if isinstance(host, IWorkfileHost): + host.open_workfile(filepath) + else: + host.open_file(filepath) + + def _host_save_workfile(self, filepath): + host = self._host + if isinstance(host, IWorkfileHost): + host.save_workfile(filepath) + else: + host.save_file(filepath) + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + # Expected selection + # - expected selection is used to restore selection after refresh + # or when current context should be used + def _trigger_expected_selection_changed(self): + self._emit_event( + "expected_selection_changed", + self._expected_selection.get_expected_selection_data(), + ) + + def _save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + src_filepath=None, + ): + # Trigger before save event + project_name = self.get_current_project_name() + folder = self.get_folder_entity(folder_id) + task = self.get_task_entity(task_id) + task_name = task["name"] + + # QUESTION should the data be different for 'before' and 'after'? + # NOTE keys should be OpenPype compatible + event_data = { + "project_name": project_name, + "folder_id": folder_id, + "asset_id": folder_id, + "asset_name": folder["name"], + "task_id": task_id, + "task_name": task_name, + "host_name": self.get_host_name(), + "filename": filename, + "workdir_path": workdir, + } + emit_event("workfile.save.before", event_data, source="workfiles.tool") + + # Create workfiles root folder + if not os.path.exists(workdir): + self.log.debug("Initializing work directory: %s", workdir) + os.makedirs(workdir) + + # Change context + if ( + folder_id != self.get_current_folder_id() + or task_name != self.get_current_task_name() + ): + # Use OpenPype asset-like object + asset_doc = get_asset_by_id(project_name, folder["id"]) + change_current_context( + asset_doc, + task["name"], + template_key=template_key + ) + + # Save workfile + dst_filepath = os.path.join(workdir, filename) + if src_filepath: + shutil.copyfile(src_filepath, dst_filepath) + self._host_open_workfile(dst_filepath) + else: + self._host_save_workfile(dst_filepath) + + # Create extra folders + create_workdir_extra_folders( + workdir, + self.get_host_name(), + task["taskType"], + task_name, + project_name + ) + + # Trigger after save events + emit_event("workfile.save.after", event_data, source="workfiles.tool") + self.refresh() diff --git a/openpype/tools/ayon_workfiles/models/__init__.py b/openpype/tools/ayon_workfiles/models/__init__.py new file mode 100644 index 0000000000..d906b9e7bd --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/__init__.py @@ -0,0 +1,10 @@ +from .hierarchy import EntitiesModel +from .selection import SelectionModel +from .workfiles import WorkfilesModel + + +__all__ = ( + "SelectionModel", + "EntitiesModel", + "WorkfilesModel", +) diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py new file mode 100644 index 0000000000..948c0b8a17 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/hierarchy.py @@ -0,0 +1,225 @@ +"""Hierarchy model that handles folders and tasks. + +The model can be extracted for common usage. In that case it will be required +to add more handling of project name changes. +""" + +import time +import collections +import contextlib + +import ayon_api + +from openpype.tools.ayon_workfiles.abstract import ( + FolderItem, + TaskItem, +) + + +def _get_task_items_from_tasks(tasks): + """ + + Returns: + TaskItem: Task item. + """ + + output = [] + for task in tasks: + folder_id = task["folderId"] + output.append(TaskItem( + task["id"], + task["name"], + task["type"], + folder_id, + None, + None + )) + return output + + +def _get_folder_item_from_hierarchy_item(item): + return FolderItem( + item["id"], + item["parentId"], + item["name"], + item["label"], + None, + None, + ) + + +class CacheItem: + def __init__(self, lifetime=120): + self._lifetime = lifetime + self._last_update = None + self._data = None + + @property + def is_valid(self): + if self._last_update is None: + return False + + return (time.time() - self._last_update) < self._lifetime + + def set_invalid(self, data=None): + self._last_update = None + self._data = data + + def get_data(self): + return self._data + + def update_data(self, data): + self._data = data + self._last_update = time.time() + + +class EntitiesModel(object): + event_source = "entities.model" + + def __init__(self, controller): + folders_cache = CacheItem() + folders_cache.set_invalid({}) + self._folders_cache = folders_cache + self._tasks_cache = {} + + self._folders_by_id = {} + self._tasks_by_id = {} + + self._folders_refreshing = False + self._tasks_refreshing = set() + self._controller = controller + + def reset(self): + self._folders_cache.set_invalid({}) + self._tasks_cache = {} + + self._folders_by_id = {} + self._tasks_by_id = {} + + def refresh(self): + self._refresh_folders_cache() + + def get_folder_items(self, sender): + if not self._folders_cache.is_valid: + self._refresh_folders_cache(sender) + return self._folders_cache.get_data() + + def get_tasks_items(self, folder_id, sender): + if not folder_id: + return [] + + task_cache = self._tasks_cache.get(folder_id) + if task_cache is None or not task_cache.is_valid: + self._refresh_tasks_cache(folder_id, sender) + task_cache = self._tasks_cache.get(folder_id) + return task_cache.get_data() + + def get_folder_entity(self, folder_id): + if folder_id not in self._folders_by_id: + entity = None + if folder_id: + project_name = self._controller.get_current_project_name() + entity = ayon_api.get_folder_by_id(project_name, folder_id) + self._folders_by_id[folder_id] = entity + return self._folders_by_id[folder_id] + + def get_task_entity(self, task_id): + if task_id not in self._tasks_by_id: + entity = None + if task_id: + project_name = self._controller.get_current_project_name() + entity = ayon_api.get_task_by_id(project_name, task_id) + self._tasks_by_id[task_id] = entity + return self._tasks_by_id[task_id] + + @contextlib.contextmanager + def _folder_refresh_event_manager(self, project_name, sender): + self._folders_refreshing = True + self._controller.emit_event( + "folders.refresh.started", + {"project_name": project_name, "sender": sender}, + self.event_source + ) + try: + yield + + finally: + self._controller.emit_event( + "folders.refresh.finished", + {"project_name": project_name, "sender": sender}, + self.event_source + ) + self._folders_refreshing = False + + @contextlib.contextmanager + def _task_refresh_event_manager( + self, project_name, folder_id, sender + ): + self._tasks_refreshing.add(folder_id) + self._controller.emit_event( + "tasks.refresh.started", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + self.event_source + ) + try: + yield + + finally: + self._controller.emit_event( + "tasks.refresh.finished", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + self.event_source + ) + self._tasks_refreshing.discard(folder_id) + + def _refresh_folders_cache(self, sender=None): + if self._folders_refreshing: + return + project_name = self._controller.get_current_project_name() + with self._folder_refresh_event_manager(project_name, sender): + folder_items = self._query_folders(project_name) + self._folders_cache.update_data(folder_items) + + def _query_folders(self, project_name): + hierarchy = ayon_api.get_folders_hierarchy(project_name) + + folder_items = {} + hierachy_queue = collections.deque(hierarchy["hierarchy"]) + while hierachy_queue: + item = hierachy_queue.popleft() + folder_item = _get_folder_item_from_hierarchy_item(item) + folder_items[folder_item.entity_id] = folder_item + hierachy_queue.extend(item["children"] or []) + return folder_items + + def _refresh_tasks_cache(self, folder_id, sender=None): + if folder_id in self._tasks_refreshing: + return + + project_name = self._controller.get_current_project_name() + with self._task_refresh_event_manager( + project_name, folder_id, sender + ): + cache_item = self._tasks_cache.get(folder_id) + if cache_item is None: + cache_item = CacheItem() + self._tasks_cache[folder_id] = cache_item + + task_items = self._query_tasks(project_name, folder_id) + cache_item.update_data(task_items) + + def _query_tasks(self, project_name, folder_id): + tasks = list(ayon_api.get_tasks( + project_name, + folder_ids=[folder_id], + fields={"id", "name", "label", "folderId", "type"} + )) + return _get_task_items_from_tasks(tasks) diff --git a/openpype/tools/ayon_workfiles/models/selection.py b/openpype/tools/ayon_workfiles/models/selection.py new file mode 100644 index 0000000000..ad034794d8 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/selection.py @@ -0,0 +1,91 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.folder.changed" + - "selection.task.changed" + - "workarea.selection.changed" + - "selection.representation.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._folder_id = None + self._task_name = None + self._task_id = None + self._workfile_path = None + self._representation_id = None + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + {"folder_id": folder_id}, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, folder_id, task_id, task_name): + if folder_id != self._folder_id: + self.set_selected_folder(folder_id) + + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "folder_id": folder_id, + "task_name": task_name, + "task_id": task_id + }, + self.event_source + ) + + def get_selected_workfile_path(self): + return self._workfile_path + + def set_selected_workfile_path(self, path): + if path == self._workfile_path: + return + + self._workfile_path = path + self._controller.emit_event( + "workarea.selection.changed", + { + "path": path, + "folder_id": self._folder_id, + "task_name": self._task_name, + "task_id": self._task_id, + }, + self.event_source + ) + + def get_selected_representation_id(self): + return self._representation_id + + def set_selected_representation_id(self, representation_id): + if representation_id == self._representation_id: + return + self._representation_id = representation_id + self._controller.emit_event( + "selection.representation.changed", + {"representation_id": representation_id}, + self.event_source + ) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py new file mode 100644 index 0000000000..eb82f62de3 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -0,0 +1,711 @@ +import os +import re +import copy + +import arrow +import ayon_api +from ayon_api.operations import OperationsSession + +from openpype.client import get_project +from openpype.client.operations import ( + prepare_workfile_info_update_data, +) +from openpype.pipeline.template_data import ( + get_template_data, +) +from openpype.pipeline.workfile import ( + get_workdir_with_workdir_data, + get_workfile_template_key, + get_last_workfile_with_version, +) +from openpype.pipeline.version_start import get_versioning_start +from openpype.tools.ayon_workfiles.abstract import ( + WorkareaFilepathResult, + FileItem, + WorkfileInfo, +) + + +def get_folder_template_data(folder): + if not folder: + return {} + parts = folder["path"].split("/") + parts.pop(-1) + hierarchy = "/".join(parts) + return { + "asset": folder["name"], + "folder": { + "name": folder["name"], + "type": folder["folderType"], + "path": folder["path"], + }, + "hierarchy": hierarchy, + } + + +def get_task_template_data(task): + if not task: + return {} + return { + "task": { + "name": task["name"], + "type": task["taskType"] + } + } + + +class CommentMatcher(object): + """Use anatomy and work file data to parse comments from filenames""" + def __init__(self, extensions, file_template, data): + self.fname_regex = None + + if "{comment}" not in file_template: + # Don't look for comment if template doesn't allow it + return + + # Create a regex group for extensions + any_extension = "(?:{})".format( + "|".join(re.escape(ext.lstrip(".")) for ext in extensions) + ) + + # Use placeholders that will never be in the filename + temp_data = copy.deepcopy(data) + temp_data["comment"] = "<>" + temp_data["version"] = "<>" + temp_data["ext"] = "<>" + + fname_pattern = file_template.format_strict(temp_data) + fname_pattern = re.escape(fname_pattern) + + # Replace comment and version with something we can match with regex + replacements = { + "<>": "(.+)", + "<>": "[0-9]+", + "<>": any_extension, + } + for src, dest in replacements.items(): + fname_pattern = fname_pattern.replace(re.escape(src), dest) + + # Match from beginning to end of string to be safe + fname_pattern = "^{}$".format(fname_pattern) + + self.fname_regex = re.compile(fname_pattern) + + def parse_comment(self, filepath): + """Parse the {comment} part from a filename""" + if not self.fname_regex: + return + + fname = os.path.basename(filepath) + match = self.fname_regex.match(fname) + if match: + return match.group(1) + + +class WorkareaModel: + """Workfiles model looking for workfiles in workare folder. + + Workarea folder is usually task and host specific, defined by + anatomy templates. Is looking for files with extensions defined + by host integration. + """ + + def __init__(self, controller): + self._controller = controller + extensions = None + if controller.is_host_valid(): + extensions = controller.get_workfile_extensions() + self._extensions = extensions + self._base_data = None + self._fill_data_by_folder_id = {} + self._task_data_by_folder_id = {} + self._workdir_by_context = {} + + @property + def project_name(self): + return self._controller.get_current_project_name() + + def reset(self): + self._base_data = None + self._fill_data_by_folder_id = {} + self._task_data_by_folder_id = {} + + def _get_base_data(self): + if self._base_data is None: + base_data = get_template_data(get_project(self.project_name)) + base_data["app"] = self._controller.get_host_name() + self._base_data = base_data + return copy.deepcopy(self._base_data) + + def _get_folder_data(self, folder_id): + fill_data = self._fill_data_by_folder_id.get(folder_id) + if fill_data is None: + folder = self._controller.get_folder_entity(folder_id) + fill_data = get_folder_template_data(folder) + self._fill_data_by_folder_id[folder_id] = fill_data + return copy.deepcopy(fill_data) + + def _get_task_data(self, folder_id, task_id): + task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) + if task_id not in task_data: + task = self._controller.get_task_entity(task_id) + if task: + task_data[task_id] = get_task_template_data(task) + return copy.deepcopy(task_data[task_id]) + + def _prepare_fill_data(self, folder_id, task_id): + if not folder_id or not task_id: + return {} + + base_data = self._get_base_data() + folder_data = self._get_folder_data(folder_id) + task_data = self._get_task_data(folder_id, task_id) + + base_data.update(folder_data) + base_data.update(task_data) + + return base_data + + def get_workarea_dir_by_context(self, folder_id, task_id): + if not folder_id or not task_id: + return None + folder_mapping = self._workdir_by_context.setdefault(folder_id, {}) + workdir = folder_mapping.get(task_id) + if workdir is not None: + return workdir + + workdir_data = self._prepare_fill_data(folder_id, task_id) + + workdir = get_workdir_with_workdir_data( + workdir_data, + self.project_name, + anatomy=self._controller.project_anatomy, + ) + folder_mapping[task_id] = workdir + return workdir + + def get_file_items(self, folder_id, task_id): + items = [] + if not folder_id or not task_id: + return items + + workdir = self.get_workarea_dir_by_context(folder_id, task_id) + if not os.path.exists(workdir): + return items + + for filename in os.listdir(workdir): + filepath = os.path.join(workdir, filename) + if not os.path.isfile(filepath): + continue + + ext = os.path.splitext(filename)[1].lower() + if ext not in self._extensions: + continue + + modified = os.path.getmtime(filepath) + items.append( + FileItem(workdir, filename, modified) + ) + return items + + def _get_template_key(self, fill_data): + task_type = fill_data.get("task", {}).get("type") + # TODO cache + return get_workfile_template_key( + task_type, + self._controller.get_host_name(), + project_name=self.project_name + ) + + def _get_last_workfile_version( + self, workdir, file_template, fill_data, extensions + ): + version = get_last_workfile_with_version( + workdir, str(file_template), fill_data, extensions + )[1] + + if version is None: + task_info = fill_data.get("task", {}) + version = get_versioning_start( + self.project_name, + self._controller.get_host_name(), + task_name=task_info.get("name"), + task_type=task_info.get("type"), + family="workfile", + project_settings=self._controller.project_settings, + ) + else: + version += 1 + return version + + def _get_comments_from_root( + self, + file_template, + extensions, + fill_data, + root, + current_filename, + ): + current_comment = None + comment_hints = set() + filenames = [] + if root and os.path.exists(root): + for filename in os.listdir(root): + path = os.path.join(root, filename) + if not os.path.isfile(path): + continue + + ext = os.path.splitext(filename)[-1].lower() + if ext in extensions: + filenames.append(filename) + + if not filenames: + return comment_hints, current_comment + + matcher = CommentMatcher(extensions, file_template, fill_data) + + for filename in filenames: + comment = matcher.parse_comment(filename) + if comment: + comment_hints.add(comment) + if filename == current_filename: + current_comment = comment + + return list(comment_hints), current_comment + + def _get_workdir(self, anatomy, template_key, fill_data): + template_info = anatomy.templates_obj[template_key] + directory_template = template_info["folder"] + return directory_template.format_strict(fill_data).normalized() + + def get_workarea_save_as_data(self, folder_id, task_id): + folder = None + task = None + if folder_id: + folder = self._controller.get_folder_entity(folder_id) + if task_id: + task = self._controller.get_task_entity(task_id) + + if not folder or not task: + return { + "template_key": None, + "template_has_version": None, + "template_has_comment": None, + "ext": None, + "workdir": None, + "comment": None, + "comment_hints": None, + "last_version": None, + "extensions": None, + } + + anatomy = self._controller.project_anatomy + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + current_workfile = self._controller.get_current_workfile() + current_filename = None + current_ext = None + if current_workfile: + current_filename = os.path.basename(current_workfile) + current_ext = os.path.splitext(current_filename)[1].lower() + + extensions = self._extensions + if not current_ext and extensions: + current_ext = tuple(extensions)[0] + + workdir = self._get_workdir(anatomy, template_key, fill_data) + + template_info = anatomy.templates_obj[template_key] + file_template = template_info["file"] + + comment_hints, comment = self._get_comments_from_root( + file_template, + extensions, + fill_data, + workdir, + current_filename, + ) + last_version = self._get_last_workfile_version( + workdir, file_template, fill_data, extensions) + str_file_template = str(file_template) + template_has_version = "{version" in str_file_template + template_has_comment = "{comment" in str_file_template + + return { + "template_key": template_key, + "template_has_version": template_has_version, + "template_has_comment": template_has_comment, + "ext": current_ext, + "workdir": workdir, + "comment": comment, + "comment_hints": comment_hints, + "last_version": last_version, + "extensions": extensions, + } + + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + anatomy = self._controller.project_anatomy + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + workdir = self._get_workdir(anatomy, template_key, fill_data) + + template_info = anatomy.templates_obj[template_key] + file_template = template_info["file"] + + if use_last_version: + version = self._get_last_workfile_version( + workdir, file_template, fill_data, self._extensions + ) + fill_data["version"] = version + fill_data["ext"] = extension.lstrip(".") + + if comment: + fill_data["comment"] = comment + + filename = file_template.format(fill_data) + if not filename.solved: + filename = None + + exists = False + if filename: + filepath = os.path.join(workdir, filename) + exists = os.path.exists(filepath) + + return WorkareaFilepathResult( + workdir, + filename, + exists + ) + + +class WorkfileEntitiesModel: + """Workfile entities model. + + Args: + control (AbstractWorkfileController): Controller object. + """ + + def __init__(self, controller): + self._controller = controller + self._cache = {} + self._items = {} + + def _get_workfile_info_identifier( + self, folder_id, task_id, rootless_path + ): + return "_".join([folder_id, task_id, rootless_path]) + + def _get_rootless_path(self, filepath): + anatomy = self._controller.project_anatomy + + workdir, filename = os.path.split(filepath) + success, rootless_dir = anatomy.find_root_template_from_path(workdir) + return "/".join([ + os.path.normpath(rootless_dir).replace("\\", "/"), + filename + ]) + + def _prepare_workfile_info_item( + self, folder_id, task_id, workfile_info, filepath + ): + note = "" + if workfile_info: + note = workfile_info["attrib"].get("description") or "" + + filestat = os.stat(filepath) + return WorkfileInfo( + folder_id, + task_id, + filepath, + filesize=filestat.st_size, + creation_time=filestat.st_ctime, + modification_time=filestat.st_mtime, + note=note + ) + + def _get_workfile_info(self, folder_id, task_id, identifier): + workfile_info = self._cache.get(identifier) + if workfile_info is not None: + return workfile_info + + for workfile_info in ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + fields=["id", "path", "attrib"], + ): + workfile_identifier = self._get_workfile_info_identifier( + folder_id, task_id, workfile_info["path"] + ) + self._cache[workfile_identifier] = workfile_info + return self._cache.get(identifier) + + def get_workfile_info( + self, folder_id, task_id, filepath, rootless_path=None + ): + if not folder_id or not task_id or not filepath: + return None + + if rootless_path is None: + rootless_path = self._get_rootless_path(filepath) + + identifier = self._get_workfile_info_identifier( + folder_id, task_id, rootless_path) + item = self._items.get(identifier) + if item is None: + workfile_info = self._get_workfile_info( + folder_id, task_id, identifier + ) + item = self._prepare_workfile_info_item( + folder_id, task_id, workfile_info, filepath + ) + self._items[identifier] = item + return item + + def save_workfile_info(self, folder_id, task_id, filepath, note): + rootless_path = self._get_rootless_path(filepath) + identifier = self._get_workfile_info_identifier( + folder_id, task_id, rootless_path + ) + workfile_info = self._get_workfile_info( + folder_id, task_id, identifier + ) + if not workfile_info: + self._cache[identifier] = self._create_workfile_info_entity( + task_id, rootless_path, note) + self._items.pop(identifier, None) + return + + new_workfile_info = copy.deepcopy(workfile_info) + attrib = new_workfile_info.setdefault("attrib", {}) + attrib["description"] = note + update_data = prepare_workfile_info_update_data( + workfile_info, new_workfile_info + ) + self._cache[identifier] = new_workfile_info + self._items.pop(identifier, None) + if not update_data: + return + + project_name = self._controller.get_current_project_name() + + session = OperationsSession() + session.update_entity( + project_name, "workfile", workfile_info["id"], update_data + ) + session.commit() + + def _create_workfile_info_entity(self, task_id, rootless_path, note): + extension = os.path.splitext(rootless_path)[1] + + project_name = self._controller.get_current_project_name() + + workfile_info = { + "path": rootless_path, + "taskId": task_id, + "attrib": { + "extension": extension, + "description": note + } + } + + session = OperationsSession() + session.create_entity(project_name, "workfile", workfile_info) + session.commit() + return workfile_info + + +class PublishWorkfilesModel: + """Model for handling of published workfiles. + + Todos: + Cache workfiles products and representations for some time. + Note Representations won't change. Only what can change are + versions. + """ + + def __init__(self, controller): + self._controller = controller + self._cached_extensions = None + self._cached_repre_extensions = None + + @property + def _extensions(self): + if self._cached_extensions is None: + exts = self._controller.get_workfile_extensions() or [] + self._cached_extensions = exts + return self._cached_extensions + + @property + def _repre_extensions(self): + if self._cached_repre_extensions is None: + self._cached_repre_extensions = { + ext.lstrip(".") for ext in self._extensions + } + return self._cached_repre_extensions + + def _file_item_from_representation( + self, repre_entity, project_anatomy, task_name=None + ): + if task_name is not None: + task_info = repre_entity["context"].get("task") + if not task_info or task_info["name"] != task_name: + return None + + # Filter by extension + extensions = self._repre_extensions + workfile_path = None + for repre_file in repre_entity["files"]: + ext = ( + os.path.splitext(repre_file["name"])[1] + .lower() + .lstrip(".") + ) + if ext in extensions: + workfile_path = repre_file["path"] + break + + if not workfile_path: + return None + + try: + workfile_path = workfile_path.format( + root=project_anatomy.roots) + except Exception as exc: + print("Failed to format workfile path: {}".format(exc)) + + dirpath, filename = os.path.split(workfile_path) + created_at = arrow.get(repre_entity["createdAt"]) + return FileItem( + dirpath, + filename, + created_at.float_timestamp, + repre_entity["id"] + ) + + def get_file_items(self, folder_id, task_name): + # TODO refactor to use less server API calls + project_name = self._controller.get_current_project_name() + # Get subset docs of asset + product_entities = ayon_api.get_products( + project_name, + folder_ids=[folder_id], + product_types=["workfile"], + fields=["id", "name"] + ) + + output = [] + product_ids = {product["id"] for product in product_entities} + if not product_ids: + return output + + # Get version docs of subsets with their families + version_entities = ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields=["id", "productId"] + ) + version_ids = {version["id"] for version in version_entities} + if not version_ids: + return output + + # Query representations of filtered versions and add filter for + # extension + repre_entities = ayon_api.get_representations( + project_name, + version_ids=version_ids + ) + project_anatomy = self._controller.project_anatomy + + # Filter queried representations by task name if task is set + file_items = [] + for repre_entity in repre_entities: + file_item = self._file_item_from_representation( + repre_entity, project_anatomy, task_name + ) + if file_item is not None: + file_items.append(file_item) + + return file_items + + +class WorkfilesModel: + """Workfiles model.""" + + def __init__(self, controller): + self._controller = controller + + self._entities_model = WorkfileEntitiesModel(controller) + self._workarea_model = WorkareaModel(controller) + self._published_model = PublishWorkfilesModel(controller) + + def get_workfile_info(self, folder_id, task_id, filepath): + return self._entities_model.get_workfile_info( + folder_id, task_id, filepath + ) + + def save_workfile_info(self, folder_id, task_id, filepath, note): + self._entities_model.save_workfile_info( + folder_id, task_id, filepath, note + ) + + def get_workarea_dir_by_context(self, folder_id, task_id): + """Workarea dir for passed context. + + The directory path is based on project anatomy templates. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + Union[str, None]: Workarea dir path or None for invalid context. + """ + + return self._workarea_model.get_workarea_dir_by_context( + folder_id, task_id) + + def get_workarea_file_items(self, folder_id, task_id): + """Workfile items for passed context from workarea. + + Args: + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[FileItem]: List of file items matching workarea of passed + context. + """ + + return self._workarea_model.get_file_items(folder_id, task_id) + + def get_workarea_save_as_data(self, folder_id, task_id): + return self._workarea_model.get_workarea_save_as_data( + folder_id, task_id) + + def fill_workarea_filepath(self, *args, **kwargs): + return self._workarea_model.fill_workarea_filepath( + *args, **kwargs + ) + + def get_published_file_items(self, folder_id, task_name): + """Published workfiles for passed context. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + + Returns: + list[FileItem]: List of files for published workfiles. + """ + + return self._published_model.get_file_items(folder_id, task_name) diff --git a/openpype/tools/ayon_workfiles/widgets/__init__.py b/openpype/tools/ayon_workfiles/widgets/__init__.py new file mode 100644 index 0000000000..f0c5ba1c40 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/__init__.py @@ -0,0 +1,6 @@ +from .window import WorkfilesToolWindow + + +__all__ = ( + "WorkfilesToolWindow", +) diff --git a/openpype/tools/ayon_workfiles/widgets/constants.py b/openpype/tools/ayon_workfiles/widgets/constants.py new file mode 100644 index 0000000000..fc74fd9bc4 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/constants.py @@ -0,0 +1,7 @@ +from qtpy import QtCore + + +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 +ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py new file mode 100644 index 0000000000..fbf4dbc593 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py @@ -0,0 +1,398 @@ +import os + +import qtpy +from qtpy import QtWidgets, QtCore + +from .save_as_dialog import SaveAsDialog +from .files_widget_workarea import WorkAreaFilesWidget +from .files_widget_published import PublishedFilesWidget + + +class FilesWidget(QtWidgets.QWidget): + """A widget displaying files that allows to save and open files. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + def __init__(self, controller, parent): + super(FilesWidget, self).__init__(parent) + + files_widget = QtWidgets.QStackedWidget(self) + workarea_widget = WorkAreaFilesWidget(controller, files_widget) + published_widget = PublishedFilesWidget(controller, files_widget) + files_widget.addWidget(workarea_widget) + files_widget.addWidget(published_widget) + + btns_widget = QtWidgets.QWidget(self) + + workarea_btns_widget = QtWidgets.QWidget(btns_widget) + workarea_btn_open = QtWidgets.QPushButton( + "Open", workarea_btns_widget) + workarea_btn_browse = QtWidgets.QPushButton( + "Browse", workarea_btns_widget) + workarea_btn_save = QtWidgets.QPushButton( + "Save As", workarea_btns_widget) + + workarea_btns_layout = QtWidgets.QHBoxLayout(workarea_btns_widget) + workarea_btns_layout.setContentsMargins(0, 0, 0, 0) + workarea_btns_layout.addWidget(workarea_btn_open, 1) + workarea_btns_layout.addWidget(workarea_btn_browse, 1) + workarea_btns_layout.addWidget(workarea_btn_save, 1) + + published_btns_widget = QtWidgets.QWidget(btns_widget) + published_btn_copy_n_open = QtWidgets.QPushButton( + "Copy && Open", published_btns_widget + ) + published_btn_change_context = QtWidgets.QPushButton( + "Choose different context", published_btns_widget + ) + published_btn_cancel = QtWidgets.QPushButton( + "Cancel", published_btns_widget + ) + + published_btns_layout = QtWidgets.QHBoxLayout(published_btns_widget) + published_btns_layout.setContentsMargins(0, 0, 0, 0) + published_btns_layout.addWidget(published_btn_copy_n_open, 1) + published_btns_layout.addWidget(published_btn_change_context, 1) + published_btns_layout.addWidget(published_btn_cancel, 1) + + btns_layout = QtWidgets.QVBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(workarea_btns_widget, 1) + btns_layout.addWidget(published_btns_widget, 1) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(files_widget, 1) + main_layout.addWidget(btns_widget, 0) + + controller.register_event_callback( + "workarea.selection.changed", + self._on_workarea_path_changed + ) + controller.register_event_callback( + "selection.representation.changed", + self._on_published_repre_changed + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "copy_representation.finished", + self._on_copy_representation_finished, + ) + controller.register_event_callback( + "workfile_save_enable.changed", + self._on_workfile_save_enabled_change, + ) + + workarea_widget.open_current_requested.connect( + self._on_current_open_requests) + workarea_widget.duplicate_requested.connect( + self._on_duplicate_request) + workarea_btn_open.clicked.connect(self._on_workarea_open_clicked) + workarea_btn_browse.clicked.connect(self._on_workarea_browse_clicked) + workarea_btn_save.clicked.connect(self._on_workarea_save_clicked) + + published_widget.save_as_requested.connect(self._on_save_as_request) + published_btn_copy_n_open.clicked.connect( + self._on_published_save_clicked) + published_btn_change_context.clicked.connect( + self._on_published_change_context_clicked) + published_btn_cancel.clicked.connect( + self._on_published_cancel_clicked) + + self._selected_folder_id = None + self._selected_tak_name = None + + self._pre_select_folder_id = None + self._pre_select_task_name = None + + self._select_context_mode = False + self._valid_selected_context = False + self._valid_representation_id = False + self._tmp_text_filter = None + self._is_save_enabled = True + + self._controller = controller + self._files_widget = files_widget + self._workarea_widget = workarea_widget + self._published_widget = published_widget + self._workarea_btns_widget = workarea_btns_widget + self._published_btns_widget = published_btns_widget + + self._workarea_btn_open = workarea_btn_open + self._workarea_btn_browse = workarea_btn_browse + self._workarea_btn_save = workarea_btn_save + + self._published_widget = published_widget + self._published_btn_copy_n_open = published_btn_copy_n_open + self._published_btn_change_context = published_btn_change_context + self._published_btn_cancel = published_btn_cancel + + # Initial setup + workarea_btn_open.setEnabled(False) + published_btn_copy_n_open.setEnabled(False) + published_btn_change_context.setEnabled(False) + published_btn_cancel.setVisible(False) + + def set_published_mode(self, published_mode): + # Make sure context selection is disabled + self._set_select_contex_mode(False) + # Change current widget + self._files_widget.setCurrentWidget(( + self._published_widget + if published_mode + else self._workarea_widget + )) + # Pass the mode to the widgets, so they can start/stop handle events + self._workarea_widget.set_published_mode(published_mode) + self._published_widget.set_published_mode(published_mode) + + # Change available buttons + self._workarea_btns_widget.setVisible(not published_mode) + self._published_btns_widget.setVisible(published_mode) + + def set_text_filter(self, text_filter): + if self._select_context_mode: + self._tmp_text_filter = text_filter + return + self._workarea_widget.set_text_filter(text_filter) + self._published_widget.set_text_filter(text_filter) + + def _exec_save_as_dialog(self): + """Show SaveAs dialog using currently selected context. + + Returns: + Union[dict[str, Any], None]: Result of the dialog. + """ + + dialog = SaveAsDialog(self._controller, self) + dialog.update_context() + dialog.exec_() + return dialog.get_result() + + # ------------------------------------------------------------- + # Workarea workfiles + # ------------------------------------------------------------- + def _open_workfile(self, filepath): + if self._controller.has_unsaved_changes(): + result = self._save_changes_prompt() + if result is None: + return + + if result: + self._controller.save_current_workfile() + self._controller.open_workfile(filepath) + + def _on_workarea_open_clicked(self): + path = self._workarea_widget.get_selected_path() + if path: + self._open_workfile(path) + + def _on_current_open_requests(self): + self._on_workarea_open_clicked() + + def _on_duplicate_request(self): + filepath = self._workarea_widget.get_selected_path() + if filepath is None: + return + + result = self._exec_save_as_dialog() + if result is None: + return + self._controller.duplicate_workfile( + filepath, + result["workdir"], + result["filename"] + ) + + def _on_workarea_browse_clicked(self): + extnsions = self._controller.get_workfile_extensions() + ext_filter = "Work File (*{0})".format( + " *".join(extnsions) + ) + dir_key = "directory" + if qtpy.API in ("pyside", "pyside2", "pyside6"): + dir_key = "dir" + + selected_context = self._controller.get_selected_context() + workfile_root = self._controller.get_workarea_dir_by_context( + selected_context["folder_id"], selected_context["task_id"] + ) + # Find existing directory of workfile root + # - Qt will use 'cwd' instead, if path does not exist, which may lead + # to igniter directory + while workfile_root: + if os.path.exists(workfile_root): + break + workfile_root = os.path.dirname(workfile_root) + + kwargs = { + "caption": "Work Files", + "filter": ext_filter, + dir_key: workfile_root + } + + filepath = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] + if filepath: + self._open_workfile(filepath) + + def _on_workarea_save_clicked(self): + result = self._exec_save_as_dialog() + if result is None: + return + self._controller.save_as_workfile( + result["folder_id"], + result["task_id"], + result["workdir"], + result["filename"], + result["template_key"], + ) + + def _on_workarea_path_changed(self, event): + valid_path = event["path"] is not None + self._workarea_btn_open.setEnabled(valid_path) + + # ------------------------------------------------------------- + # Published workfiles + # ------------------------------------------------------------- + def _update_published_btns_state(self): + enabled = ( + self._valid_representation_id + and self._valid_selected_context + and self._is_save_enabled + ) + self._published_btn_copy_n_open.setEnabled(enabled) + self._published_btn_change_context.setEnabled(enabled) + + def _update_workarea_btns_state(self): + enabled = self._is_save_enabled + self._workarea_btn_save.setEnabled(enabled) + + def _on_published_repre_changed(self, event): + self._valid_representation_id = event["representation_id"] is not None + self._update_published_btns_state() + + def _on_task_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._selected_tak_name = event["task_name"] + self._valid_selected_context = ( + self._selected_folder_id is not None + and self._selected_tak_name is not None + ) + self._update_published_btns_state() + + def _on_published_save_clicked(self): + result = self._exec_save_as_dialog() + if result is None: + return + + repre_info = self._published_widget.get_selected_repre_info() + self._controller.copy_workfile_representation( + repre_info["representation_id"], + repre_info["filepath"], + result["folder_id"], + result["task_id"], + result["workdir"], + result["filename"], + result["template_key"], + ) + + def _on_save_as_request(self): + self._on_published_save_clicked() + + def _set_select_contex_mode(self, enabled): + if self._select_context_mode is enabled: + return + + if enabled: + self._pre_select_folder_id = self._selected_folder_id + self._pre_select_task_name = self._selected_tak_name + else: + self._pre_select_folder_id = None + self._pre_select_task_name = None + self._select_context_mode = enabled + self._published_btn_cancel.setVisible(enabled) + self._published_btn_change_context.setVisible(not enabled) + self._published_widget.set_select_context_mode(enabled) + + if not enabled and self._tmp_text_filter is not None: + self.set_text_filter(self._tmp_text_filter) + self._tmp_text_filter = None + + def _on_published_change_context_clicked(self): + self._set_select_contex_mode(True) + + def _should_set_pre_select_context(self): + if self._pre_select_folder_id is None: + return False + if self._pre_select_folder_id != self._selected_folder_id: + return True + if self._pre_select_task_name is None: + return False + return self._pre_select_task_name != self._selected_tak_name + + def _on_published_cancel_clicked(self): + folder_id = self._pre_select_folder_id + task_name = self._pre_select_task_name + representation_id = self._published_widget.get_selected_repre_id() + should_change_selection = self._should_set_pre_select_context() + self._set_select_contex_mode(False) + if should_change_selection: + self._controller.set_expected_selection( + folder_id, task_name, representation_id=representation_id + ) + + def _on_copy_representation_finished(self, event): + """Callback for when copy representation is finished. + + Make sure that select context mode is disabled when representation + copy is finished. + + Args: + event (Event): Event object. + """ + + if not event["failed"]: + self._set_select_contex_mode(False) + + def _on_workfile_save_enabled_change(self, event): + enabled = event["enabled"] + self._is_save_enabled = enabled + self._update_published_btns_state() + self._update_workarea_btns_state() + + def _save_changes_prompt(self): + """Ask user if wants to save changes to current file. + + Returns: + Union[bool, None]: True if user wants to save changes, False if + user does not want to save changes, None if user cancels + operation. + """ + messagebox = QtWidgets.QMessageBox(parent=self) + messagebox.setWindowFlags( + messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + messagebox.setIcon(QtWidgets.QMessageBox.Warning) + messagebox.setWindowTitle("Unsaved Changes!") + messagebox.setText( + "There are unsaved changes to the current file." + "\nDo you want to save the changes?" + ) + messagebox.setStandardButtons( + QtWidgets.QMessageBox.Yes + | QtWidgets.QMessageBox.No + | QtWidgets.QMessageBox.Cancel + ) + + result = messagebox.exec_() + if result == QtWidgets.QMessageBox.Yes: + return True + if result == QtWidgets.QMessageBox.No: + return False + return None diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py new file mode 100644 index 0000000000..bc59447777 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py @@ -0,0 +1,378 @@ +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .utils import TreeView, BaseOverlayFrame + + +REPRE_ID_ROLE = QtCore.Qt.UserRole + 1 +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 + + +class PublishedFilesModel(QtGui.QStandardItemModel): + """A model for displaying files. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller): + super(PublishedFilesModel, self).__init__() + + self.setColumnCount(2) + + self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") + self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_changed + ) + + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._controller = controller + self._items_by_id = {} + self._missing_context_item = None + self._missing_context_used = False + self._empty_root_item = None + self._empty_item_used = False + + self._published_mode = False + self._context_select_mode = False + + self._last_folder_id = None + self._last_task_id = None + + self._add_empty_item() + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_id: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_id = {} + + def set_published_mode(self, published_mode): + if self._published_mode == published_mode: + return + self._published_mode = published_mode + if published_mode: + self._fill_items() + elif self._context_select_mode: + self.set_select_context_mode(False) + + def set_select_context_mode(self, select_mode): + if self._context_select_mode is select_mode: + return + self._context_select_mode = select_mode + if not select_mode and self._published_mode: + self._fill_items() + + def get_index_by_representation_id(self, representation_id): + item = self._items_by_id.get(representation_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def _get_missing_context_item(self): + if self._missing_context_item is None: + message = "Select folder" + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._missing_context_item = item + return self._missing_context_item + + def _add_missing_context_item(self): + if self._missing_context_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_missing_context_item()) + self._missing_context_used = True + + def _remove_missing_context_item(self): + if not self._missing_context_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._missing_context_item.row()) + self._missing_context_used = False + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Didn't find any published workfiles." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def _add_empty_item(self): + if self._empty_item_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_empty_root_item()) + self._empty_item_used = True + + def _remove_empty_item(self): + if not self._empty_item_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._empty_root_item.row()) + self._empty_item_used = False + + def _on_folder_changed(self, event): + self._last_folder_id = event["folder_id"] + self._last_task_id = None + if self._context_select_mode: + return + + if self._published_mode: + self._fill_items() + + def _on_task_changed(self, event): + self._last_folder_id = event["folder_id"] + self._last_task_id = event["task_id"] + if self._context_select_mode: + return + + if self._published_mode: + self._fill_items() + + def _fill_items(self): + folder_id = self._last_folder_id + task_id = self._last_task_id + if not folder_id: + self._add_missing_context_item() + return + + file_items = self._controller.get_published_file_items( + folder_id, task_id + ) + root_item = self.invisibleRootItem() + if not file_items: + self._add_empty_item() + return + self._remove_empty_item() + self._remove_missing_context_item() + + items_to_remove = set(self._items_by_id.keys()) + new_items = [] + for file_item in file_items: + repre_id = file_item.representation_id + if repre_id in self._items_by_id: + items_to_remove.discard(repre_id) + item = self._items_by_id[repre_id] + else: + item = QtGui.QStandardItem() + new_items.append(item) + item.setColumnCount(self.columnCount()) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + item.setData(file_item.filename, QtCore.Qt.DisplayRole) + item.setData(repre_id, REPRE_ID_ROLE) + + if file_item.exists: + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + else: + flags = QtCore.Qt.NoItemFlags + + item.setFlags(flags) + item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.modified, DATE_MODIFIED_ROLE) + + self._items_by_id[repre_id] = item + + if new_items: + root_item.appendRows(new_items) + + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + root_item.removeRow(item.row()) + + if root_item.rowCount() == 0: + self._add_empty_item() + + def flags(self, index): + # Use flags of first column for all columns + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(PublishedFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + # Handle roles for first column + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(PublishedFilesModel, self).data(index, role) + + +class SelectContextOverlay(BaseOverlayFrame): + """Overlay for files view when user should select context. + + Todos: + The look of this overlay should be improved, it is "not nice" now. + """ + + def __init__(self, parent): + super(SelectContextOverlay, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + "Please choose context on the left
<", + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setObjectName("OverlayFrameLabel") + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class PublishedFilesWidget(QtWidgets.QWidget): + """Published workfiles widget. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + selection_changed = QtCore.Signal() + save_as_requested = QtCore.Signal() + + def __init__(self, controller, parent): + super(PublishedFilesWidget, self).__init__(parent) + + view = TreeView(self) + view.setSortingEnabled(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Smaller indentation + view.setIndentation(0) + + model = PublishedFilesModel(controller) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setDynamicSortFilter(True) + + view.setModel(proxy_model) + + time_delegate = PrettyTimeDelegate() + view.setItemDelegateForColumn(1, time_delegate) + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + view.setColumnWidth(0, 330) + + select_overlay = SelectContextOverlay(view) + select_overlay.setVisible(False) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(view, 1) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + view.double_clicked_left.connect(self._on_left_double_click) + + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + self._view = view + self._select_overlay = select_overlay + self._model = model + self._proxy_model = proxy_model + self._time_delegate = time_delegate + self._controller = controller + + def set_published_mode(self, published_mode): + self._model.set_published_mode(published_mode) + + def set_select_context_mode(self, select_mode): + self._model.set_select_context_mode(select_mode) + self._select_overlay.setVisible(select_mode) + + def set_text_filter(self, text_filter): + self._proxy_model.setFilterFixedString(text_filter) + + def get_selected_repre_info(self): + selection_model = self._view.selectionModel() + representation_id = None + filepath = None + for index in selection_model.selectedIndexes(): + representation_id = index.data(REPRE_ID_ROLE) + filepath = index.data(FILEPATH_ROLE) + + return { + "representation_id": representation_id, + "filepath": filepath, + } + + def get_selected_repre_id(self): + return self.get_selected_repre_info()["representation_id"] + + def _on_selection_change(self): + repre_id = self.get_selected_repre_id() + self._controller.set_selected_representation_id(repre_id) + + def _on_left_double_click(self): + self.save_as_requested.emit() + + def _on_expected_selection_change(self, event): + if ( + event["representation_id_selected"] + or not event["folder_selected"] + or (event["task_name"] and not event["task_selected"]) + ): + return + + representation_id = event["representation_id"] + selected_repre_id = self.get_selected_repre_id() + if ( + representation_id is not None + and representation_id != selected_repre_id + ): + index = self._model.get_index_by_representation_id( + representation_id) + if index.isValid(): + proxy_index = self._proxy_model.mapFromSource(index) + self._view.setCurrentIndex(proxy_index) + + self._controller.expected_representation_selected( + event["folder_id"], event["task_name"], representation_id + ) diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py new file mode 100644 index 0000000000..e8ccd094d1 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -0,0 +1,380 @@ +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .utils import TreeView + +FILENAME_ROLE = QtCore.Qt.UserRole + 1 +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 + + +class WorkAreaFilesModel(QtGui.QStandardItemModel): + """A model for workare workfiles. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller): + super(WorkAreaFilesModel, self).__init__() + + self.setColumnCount(2) + + self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") + self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "workfile_duplicate.finished", + self._on_duplicate_finished + ) + controller.register_event_callback( + "save_as.finished", + self._on_save_as_finished + ) + + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._controller = controller + self._items_by_filename = {} + self._missing_context_item = None + self._missing_context_used = False + self._empty_root_item = None + self._empty_item_used = False + self._published_mode = False + self._selected_folder_id = None + self._selected_task_id = None + + self._add_missing_context_item() + + def get_index_by_filename(self, filename): + item = self._items_by_filename.get(filename) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def _get_missing_context_item(self): + if self._missing_context_item is None: + message = "Select folder and task" + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._missing_context_item = item + return self._missing_context_item + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_filename: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_filename = {} + + def _add_missing_context_item(self): + if self._missing_context_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_missing_context_item()) + self._missing_context_used = True + + def _remove_missing_context_item(self): + if not self._missing_context_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._missing_context_item.row()) + self._missing_context_used = False + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Work Area is empty.." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.exclamation-circle", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def _add_empty_item(self): + if self._empty_item_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_empty_root_item()) + self._empty_item_used = True + + def _remove_empty_item(self): + if not self._empty_item_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._empty_root_item.row()) + self._empty_item_used = False + + def _on_task_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + if not self._published_mode: + self._fill_items() + + def _on_duplicate_finished(self, event): + if event["failed"]: + return + + if not self._published_mode: + self._fill_items() + + def _on_save_as_finished(self, event): + if event["failed"]: + return + + if not self._published_mode: + self._fill_items() + + def _fill_items(self): + folder_id = self._selected_folder_id + task_id = self._selected_task_id + if not folder_id or not task_id: + self._add_missing_context_item() + return + + file_items = self._controller.get_workarea_file_items( + folder_id, task_id + ) + root_item = self.invisibleRootItem() + if not file_items: + self._add_empty_item() + return + self._remove_empty_item() + self._remove_missing_context_item() + + items_to_remove = set(self._items_by_filename.keys()) + new_items = [] + for file_item in file_items: + filename = file_item.filename + if filename in self._items_by_filename: + items_to_remove.discard(filename) + item = self._items_by_filename[filename] + else: + item = QtGui.QStandardItem() + new_items.append(item) + item.setColumnCount(self.columnCount()) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + item.setData(file_item.filename, QtCore.Qt.DisplayRole) + item.setData(file_item.filename, FILENAME_ROLE) + + item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.modified, DATE_MODIFIED_ROLE) + + self._items_by_filename[file_item.filename] = item + + if new_items: + root_item.appendRows(new_items) + + for filename in items_to_remove: + item = self._items_by_filename.pop(filename) + root_item.removeRow(item.row()) + + if root_item.rowCount() == 0: + self._add_empty_item() + + def flags(self, index): + # Use flags of first column for all columns + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(WorkAreaFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + # Handle roles for first column + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(WorkAreaFilesModel, self).data(index, role) + + def set_published_mode(self, published_mode): + if self._published_mode == published_mode: + return + self._published_mode = published_mode + if not published_mode: + self._fill_items() + + +class WorkAreaFilesWidget(QtWidgets.QWidget): + """Workarea files widget. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + selection_changed = QtCore.Signal() + open_current_requested = QtCore.Signal() + duplicate_requested = QtCore.Signal() + + def __init__(self, controller, parent): + super(WorkAreaFilesWidget, self).__init__(parent) + + view = TreeView(self) + view.setSortingEnabled(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Smaller indentation + view.setIndentation(0) + + model = WorkAreaFilesModel(controller) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setDynamicSortFilter(True) + + view.setModel(proxy_model) + + time_delegate = PrettyTimeDelegate() + view.setItemDelegateForColumn(1, time_delegate) + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + view.setColumnWidth(0, 330) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(view, 1) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + view.double_clicked_left.connect(self._on_left_double_click) + view.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + self._view = view + self._model = model + self._proxy_model = proxy_model + self._time_delegate = time_delegate + self._controller = controller + + self._published_mode = False + + def set_published_mode(self, published_mode): + """Set the published mode. + + Widget should ignore most of events when in published mode is enabled. + + Args: + published_mode (bool): The published mode. + """ + + self._model.set_published_mode(published_mode) + self._published_mode = published_mode + + def set_text_filter(self, text_filter): + """Set the text filter. + + Args: + text_filter (str): The text filter. + """ + + self._proxy_model.setFilterFixedString(text_filter) + + def _get_selected_info(self): + selection_model = self._view.selectionModel() + filepath = None + filename = None + for index in selection_model.selectedIndexes(): + filepath = index.data(FILEPATH_ROLE) + filename = index.data(FILENAME_ROLE) + return { + "filepath": filepath, + "filename": filename, + } + + def get_selected_path(self): + """Selected filepath. + + Returns: + Union[str, None]: The selected filepath or None if nothing is + selected. + """ + return self._get_selected_info()["filepath"] + + def _on_selection_change(self): + filepath = self.get_selected_path() + self._controller.set_selected_workfile_path(filepath) + + def _on_left_double_click(self): + self.open_current_requested.emit() + + def _on_context_menu(self, point): + index = self._view.indexAt(point) + if not index.isValid(): + return + + if not index.flags() & QtCore.Qt.ItemIsEnabled: + return + + menu = QtWidgets.QMenu(self) + + # Duplicate + action = QtWidgets.QAction("Duplicate", menu) + tip = "Duplicate selected file." + action.setToolTip(tip) + action.setStatusTip(tip) + action.triggered.connect(self._on_duplicate_pressed) + menu.addAction(action) + + # Show the context action menu + global_point = self._view.mapToGlobal(point) + _ = menu.exec_(global_point) + + def _on_duplicate_pressed(self): + self.duplicate_requested.emit() + + def _on_expected_selection_change(self, event): + if event["workfile_name_selected"]: + return + + workfile_name = event["workfile_name"] + if ( + workfile_name is not None + and workfile_name != self._get_selected_info()["filename"] + ): + index = self._model.get_index_by_filename(workfile_name) + if index.isValid(): + proxy_index = self._proxy_model.mapFromSource(index) + self._view.setCurrentIndex(proxy_index) + + self._controller.expected_workfile_selected( + event["folder_id"], event["task_name"], workfile_name + ) diff --git a/openpype/tools/ayon_workfiles/widgets/folders_widget.py b/openpype/tools/ayon_workfiles/widgets/folders_widget.py new file mode 100644 index 0000000000..b35845f4b6 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/folders_widget.py @@ -0,0 +1,324 @@ +import uuid +import collections + +import qtawesome +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) + +from .constants import ITEM_ID_ROLE, ITEM_NAME_ROLE + +SENDER_NAME = "qt_folders_model" + + +class FoldersRefreshThread(QtCore.QThread): + """Thread for refreshing folders. + + Call controller to get folders and emit signal when finished. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refresh_finished = QtCore.Signal(str) + + def __init__(self, controller): + super(FoldersRefreshThread, self).__init__() + self._id = uuid.uuid4().hex + self._controller = controller + self._result = None + + @property + def id(self): + """Thread id. + + Returns: + str: Unique id of the thread. + """ + + return self._id + + def run(self): + self._result = self._controller.get_folder_items(SENDER_NAME) + self.refresh_finished.emit(self.id) + + def get_result(self): + return self._result + + +class FoldersModel(QtGui.QStandardItemModel): + """Folders model which cares about refresh of folders. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(FoldersModel, self).__init__() + + self._controller = controller + self._items_by_id = {} + self._parent_id_by_id = {} + + self._refresh_threads = {} + self._current_refresh_thread = None + + self._has_content = False + self._is_refreshing = False + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + return self._is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._has_content + + def clear(self): + self._items_by_id = {} + self._parent_id_by_id = {} + self._has_content = False + super(FoldersModel, self).clear() + + def get_index_by_id(self, item_id): + """Get index by folder id. + + Returns: + QtCore.QModelIndex: Index of the folder. Can be invalid if folder + is not available. + """ + item = self._items_by_id.get(item_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def refresh(self): + """Refresh folders items. + + Refresh start thread because it can cause that controller can + start query from database if folders are not cached. + """ + + self._is_refreshing = True + + thread = FoldersRefreshThread(self._controller) + self._current_refresh_thread = thread.id + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Folders are stored by id. + + Args: + thread_id (str): Thread id. + """ + + thread = self._refresh_threads.pop(thread_id) + if thread_id != self._current_refresh_thread: + return + + folder_items_by_id = thread.get_result() + if not folder_items_by_id: + if folder_items_by_id is not None: + self.clear() + self._is_refreshing = False + return + + self._has_content = True + + folder_ids = set(folder_items_by_id) + ids_to_remove = set(self._items_by_id) - folder_ids + + folder_items_by_parent = collections.defaultdict(list) + for folder_item in folder_items_by_id.values(): + folder_items_by_parent[folder_item.parent_id].append(folder_item) + + hierarchy_queue = collections.deque() + hierarchy_queue.append(None) + + while hierarchy_queue: + parent_id = hierarchy_queue.popleft() + folder_items = folder_items_by_parent[parent_id] + if parent_id is None: + parent_item = self.invisibleRootItem() + else: + parent_item = self._items_by_id[parent_id] + + new_items = [] + for folder_item in folder_items: + item_id = folder_item.entity_id + item = self._items_by_id.get(item_id) + if item is None: + is_new = True + item = QtGui.QStandardItem() + item.setEditable(False) + else: + is_new = self._parent_id_by_id[item_id] != parent_id + + icon = qtawesome.icon( + folder_item.icon_name, + color=folder_item.icon_color, + ) + item.setData(item_id, ITEM_ID_ROLE) + item.setData(folder_item.name, ITEM_NAME_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + if is_new: + new_items.append(item) + self._items_by_id[item_id] = item + self._parent_id_by_id[item_id] = parent_id + + hierarchy_queue.append(item_id) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in ids_to_remove: + item = self._items_by_id[item_id] + parent_id = self._parent_id_by_id[item_id] + if parent_id is None: + parent_item = self.invisibleRootItem() + else: + parent_item = self._items_by_id[parent_id] + parent_item.takeChild(item.row()) + + for item_id in ids_to_remove: + self._items_by_id.pop(item_id) + self._parent_id_by_id.pop(item_id) + + self._is_refreshing = False + self.refreshed.emit() + + +class FoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + def __init__(self, controller, parent): + super(FoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + + folders_model = FoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + + folders_view.setModel(folders_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + + self._expected_selection = None + + def set_name_filer(self, name): + self._folders_proxy_model.setFilterFixedString(name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != SENDER_NAME: + self._folders_model.refresh() + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _update_expected_selection(self, expected_data=None): + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + # We're done + if expected_data["folder_selected"]: + return + + folder_id = expected_data["folder_id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + folder_id = self._expected_selection + self._expected_selection = None + if ( + folder_id is not None + and folder_id != self._get_selected_item_id() + ): + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _get_selected_item_id(self): + selection_model = self._folders_view.selectionModel() + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is not None: + return item_id + return None + + def _on_selection_change(self): + item_id = self._get_selected_item_id() + self._controller.set_selected_folder(item_id) diff --git a/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py new file mode 100644 index 0000000000..cdce73f030 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py @@ -0,0 +1,351 @@ +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import PlaceholderLineEdit + + +class SubversionLineEdit(QtWidgets.QWidget): + """QLineEdit with QPushButton for drop down selection of list of strings""" + + text_changed = QtCore.Signal(str) + + def __init__(self, *args, **kwargs): + super(SubversionLineEdit, self).__init__(*args, **kwargs) + + input_field = PlaceholderLineEdit(self) + menu_btn = QtWidgets.QPushButton(self) + menu_btn.setFixedWidth(18) + + menu = QtWidgets.QMenu(self) + menu_btn.setMenu(menu) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + layout.addWidget(input_field, 1) + layout.addWidget(menu_btn, 0) + + input_field.textChanged.connect(self.text_changed) + + self.setFocusProxy(input_field) + + self._input_field = input_field + self._menu_btn = menu_btn + self._menu = menu + + def set_placeholder(self, placeholder): + self._input_field.setPlaceholderText(placeholder) + + def set_text(self, text): + self._input_field.setText(text) + + def set_values(self, values): + self._update(values) + + def _on_button_clicked(self): + self._menu.exec_() + + def _on_action_clicked(self, action): + self._input_field.setText(action.text()) + + def _update(self, values): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + menu = self._menu + button = self._menu_btn + + state = any(values) + button.setEnabled(state) + if state is False: + return + + # Include an empty string + values = [""] + sorted(values) + + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + # Build new action group + group = QtWidgets.QActionGroup(button) + for name in values: + action = group.addAction(name) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + +class SaveAsDialog(QtWidgets.QDialog): + """Save as dialog to define a unique filename inside workdir. + + The filename is calculated in controller where UI sends values from + dialog inputs. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller, parent): + super(SaveAsDialog, self).__init__(parent=parent) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) + + self._controller = controller + + self._folder_id = None + self._task_id = None + self._last_version = None + self._template_key = None + self._comment_value = None + self._version_value = None + self._ext_value = None + self._filename = None + self._workdir = None + + self._result = None + + # Btns widget + btns_widget = QtWidgets.QWidget(self) + + btn_ok = QtWidgets.QPushButton("Ok", btns_widget) + btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addWidget(btn_ok) + btns_layout.addWidget(btn_cancel) + + # Inputs widget + inputs_widget = QtWidgets.QWidget(self) + + # Version widget + version_widget = QtWidgets.QWidget(inputs_widget) + + # Version number input + version_input = QtWidgets.QSpinBox(version_widget) + version_input.setMinimum(1) + version_input.setMaximum(9999) + + # Last version checkbox + last_version_check = QtWidgets.QCheckBox( + "Next Available Version", version_widget + ) + last_version_check.setChecked(True) + + version_layout = QtWidgets.QHBoxLayout(version_widget) + version_layout.setContentsMargins(0, 0, 0, 0) + version_layout.addWidget(version_input) + version_layout.addWidget(last_version_check) + + # Preview widget + preview_widget = QtWidgets.QLabel("Preview filename", inputs_widget) + preview_widget.setWordWrap(True) + + # Subversion input + subversion_input = SubversionLineEdit(inputs_widget) + subversion_input.set_placeholder("Will be part of filename.") + + # Extensions combobox + extension_combobox = QtWidgets.QComboBox(inputs_widget) + # Add styled delegate to use stylesheets + extension_delegate = QtWidgets.QStyledItemDelegate() + extension_combobox.setItemDelegate(extension_delegate) + + version_label = QtWidgets.QLabel("Version:", inputs_widget) + subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget) + extension_label = QtWidgets.QLabel("Extension:", inputs_widget) + preview_label = QtWidgets.QLabel("Preview:", inputs_widget) + + # Build inputs + inputs_layout = QtWidgets.QGridLayout(inputs_widget) + inputs_layout.addWidget(version_label, 0, 0) + inputs_layout.addWidget(version_widget, 0, 1) + inputs_layout.addWidget(subversion_label, 1, 0) + inputs_layout.addWidget(subversion_input, 1, 1) + inputs_layout.addWidget(extension_label, 2, 0) + inputs_layout.addWidget(extension_combobox, 2, 1) + inputs_layout.addWidget(preview_label, 3, 0) + inputs_layout.addWidget(preview_widget, 3, 1) + + # Build layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget) + main_layout.addWidget(btns_widget) + + # Signal callback registration + version_input.valueChanged.connect(self._on_version_spinbox_change) + last_version_check.stateChanged.connect( + self._on_version_checkbox_change + ) + + subversion_input.text_changed.connect(self._on_comment_change) + extension_combobox.currentIndexChanged.connect( + self._on_extension_change) + + btn_ok.pressed.connect(self._on_ok_pressed) + btn_cancel.pressed.connect(self._on_cancel_pressed) + + # Store objects + self._inputs_layout = inputs_layout + + self._btn_ok = btn_ok + self._btn_cancel = btn_cancel + + self._version_widget = version_widget + + self._version_input = version_input + self._last_version_check = last_version_check + + self._extension_delegate = extension_delegate + self._extension_combobox = extension_combobox + self._subversion_input = subversion_input + self._preview_widget = preview_widget + + self._version_label = version_label + self._subversion_label = subversion_label + self._extension_label = extension_label + self._preview_label = preview_label + + # Post init setup + + # Allow "Enter" key to accept the save. + btn_ok.setDefault(True) + + # Disable version input if last version is checked + version_input.setEnabled(not last_version_check.isChecked()) + + # Force default focus to comment, some hosts didn't automatically + # apply focus to this line edit (e.g. Houdini) + subversion_input.setFocus() + + def get_result(self): + return self._result + + def update_context(self): + # Add version only if template contains version key + # - since the version can be padded with "{version:0>4}" we only search + # for "{version". + selected_context = self._controller.get_selected_context() + folder_id = selected_context["folder_id"] + task_id = selected_context["task_id"] + data = self._controller.get_workarea_save_as_data(folder_id, task_id) + last_version = data["last_version"] + comment = data["comment"] + comment_hints = data["comment_hints"] + + template_has_version = data["template_has_version"] + template_has_comment = data["template_has_comment"] + + self._folder_id = folder_id + self._task_id = task_id + self._workdir = data["workdir"] + self._comment_value = data["comment"] + self._ext_value = data["ext"] + self._template_key = data["template_key"] + self._last_version = data["last_version"] + + self._extension_combobox.clear() + self._extension_combobox.addItems(data["extensions"]) + + self._version_input.setValue(last_version) + + vw_idx = self._inputs_layout.indexOf(self._version_widget) + self._version_label.setVisible(template_has_version) + self._version_widget.setVisible(template_has_version) + if template_has_version: + if vw_idx == -1: + self._inputs_layout.addWidget(self._version_label, 0, 0) + self._inputs_layout.addWidget(self._version_widget, 0, 1) + elif vw_idx != -1: + self._inputs_layout.takeAt(vw_idx) + self._inputs_layout.takeAt( + self._inputs_layout.indexOf(self._version_label) + ) + + cw_idx = self._inputs_layout.indexOf(self._subversion_input) + self._subversion_label.setVisible(template_has_comment) + self._subversion_input.setVisible(template_has_comment) + if template_has_comment: + if cw_idx == -1: + self._inputs_layout.addWidget(self._subversion_label, 1, 0) + self._inputs_layout.addWidget(self._subversion_input, 1, 1) + elif cw_idx != -1: + self._inputs_layout.takeAt(cw_idx) + self._inputs_layout.takeAt( + self._inputs_layout.indexOf(self._subversion_label) + ) + + if template_has_comment: + self._subversion_input.set_text(comment or "") + self._subversion_input.set_values(comment_hints) + self._update_filename() + + def _on_version_spinbox_change(self, value): + if value == self._version_value: + return + self._version_value = value + if not self._last_version_check.isChecked(): + self._update_filename() + + def _on_version_checkbox_change(self): + use_last_version = self._last_version_check.isChecked() + self._version_input.setEnabled(not use_last_version) + if use_last_version: + self._version_input.blockSignals(True) + self._version_input.setValue(self._last_version) + self._version_input.blockSignals(False) + self._update_filename() + + def _on_comment_change(self, text): + if self._comment_value == text: + return + self._comment_value = text + self._update_filename() + + def _on_extension_change(self): + ext = self._extension_combobox.currentText() + if ext == self._ext_value: + return + self._ext_value = ext + self._update_filename() + + def _on_ok_pressed(self): + self._result = { + "filename": self._filename, + "workdir": self._workdir, + "folder_id": self._folder_id, + "task_id": self._task_id, + "template_key": self._template_key, + } + self.close() + + def _on_cancel_pressed(self): + self.close() + + def _update_filename(self): + result = self._controller.fill_workarea_filepath( + self._folder_id, + self._task_id, + self._ext_value, + self._last_version_check.isChecked(), + self._version_value, + self._comment_value, + ) + self._filename = result.filename + self._btn_ok.setEnabled(not result.exists) + + if result.exists: + self._preview_widget.setText(( + "Cannot create \"{}\" because file exists!" + "" + ).format(result.filename)) + else: + self._preview_widget.setText( + "{}".format(result.filename) + ) diff --git a/openpype/tools/ayon_workfiles/widgets/side_panel.py b/openpype/tools/ayon_workfiles/widgets/side_panel.py new file mode 100644 index 0000000000..7f06576a00 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/side_panel.py @@ -0,0 +1,163 @@ +import datetime + +from qtpy import QtWidgets, QtCore + + +def file_size_to_string(file_size): + size = 0 + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if file_size < _size: + break + size = file_size / _size + ending = _ending + return "{:.2f} {}".format(size, ending) + + +class SidePanelWidget(QtWidgets.QWidget): + """Details about selected workfile. + + Todos: + At this moment only shows created and modified date of file + or its size. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + published_workfile_message = ( + "INFO: Opened published workfiles will be stored in" + " temp directory on your machine. Current temp size: {}." + ) + + def __init__(self, controller, parent): + super(SidePanelWidget, self).__init__(parent) + + details_label = QtWidgets.QLabel("Details", self) + details_input = QtWidgets.QPlainTextEdit(self) + details_input.setReadOnly(True) + + artist_note_widget = QtWidgets.QWidget(self) + note_label = QtWidgets.QLabel("Artist note", artist_note_widget) + note_input = QtWidgets.QPlainTextEdit(artist_note_widget) + btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget) + + artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget) + artist_note_layout.setContentsMargins(0, 0, 0, 0) + artist_note_layout.addWidget(note_label, 0) + artist_note_layout.addWidget(note_input, 1) + artist_note_layout.addWidget( + btn_note_save, 0, alignment=QtCore.Qt.AlignRight + ) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(details_label, 0) + main_layout.addWidget(details_input, 1) + main_layout.addWidget(artist_note_widget, 1) + + note_input.textChanged.connect(self._on_note_change) + btn_note_save.clicked.connect(self._on_save_click) + + controller.register_event_callback( + "workarea.selection.changed", self._on_selection_change + ) + + self._details_input = details_input + self._artist_note_widget = artist_note_widget + self._note_input = note_input + self._btn_note_save = btn_note_save + + self._folder_id = None + self._task_id = None + self._filepath = None + self._orig_note = "" + self._controller = controller + + self._set_context(None, None, None) + + def set_published_mode(self, published_mode): + """Change published mode. + + Args: + published_mode (bool): Published mode enabled. + """ + + self._artist_note_widget.setVisible(not published_mode) + + def _on_selection_change(self, event): + folder_id = event["folder_id"] + task_id = event["task_id"] + filepath = event["path"] + + self._set_context(folder_id, task_id, filepath) + + def _on_note_change(self): + text = self._note_input.toPlainText() + self._btn_note_save.setEnabled(self._orig_note != text) + + def _on_save_click(self): + note = self._note_input.toPlainText() + self._controller.save_workfile_info( + self._folder_id, + self._task_id, + self._filepath, + note + ) + self._orig_note = note + self._btn_note_save.setEnabled(False) + + def _set_context(self, folder_id, task_id, filepath): + workfile_info = None + # Check if folder, task and file are selected + if bool(folder_id) and bool(task_id) and bool(filepath): + workfile_info = self._controller.get_workfile_info( + folder_id, task_id, filepath + ) + enabled = workfile_info is not None + + self._details_input.setEnabled(enabled) + self._note_input.setEnabled(enabled) + self._btn_note_save.setEnabled(enabled) + + self._folder_id = folder_id + self._task_id = task_id + self._filepath = filepath + + # Disable inputs and remove texts if any required arguments are + # missing + if not enabled: + self._orig_note = "" + self._details_input.setPlainText("") + self._note_input.setPlainText("") + return + + note = workfile_info.note + size_value = file_size_to_string(workfile_info.filesize) + + # Append html string + datetime_format = "%b %d %Y %H:%M:%S" + creation_time = datetime.datetime.fromtimestamp( + workfile_info.creation_time) + modification_time = datetime.datetime.fromtimestamp( + workfile_info.modification_time) + lines = ( + "Size:", + size_value, + "Created:", + creation_time.strftime(datetime_format), + "Modified:", + modification_time.strftime(datetime_format) + ) + self._orig_note = note + self._note_input.setPlainText(note) + + # Set as empty string + self._details_input.setPlainText("") + self._details_input.appendHtml("
".join(lines)) diff --git a/openpype/tools/ayon_workfiles/widgets/tasks_widget.py b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py new file mode 100644 index 0000000000..04f5b286b1 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py @@ -0,0 +1,420 @@ +import uuid +import qtawesome +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import get_disabled_entity_icon_color +from openpype.tools.utils import DeselectableTreeView + +from .constants import ( + ITEM_NAME_ROLE, + ITEM_ID_ROLE, + PARENT_ID_ROLE, +) + +SENDER_NAME = "qt_tasks_model" + + +class RefreshThread(QtCore.QThread): + """Thread for refreshing tasks. + + Call controller to get tasks and emit signal when finished. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + folder_id (str): Folder id. + """ + + refresh_finished = QtCore.Signal(str) + + def __init__(self, controller, folder_id): + super(RefreshThread, self).__init__() + self._id = uuid.uuid4().hex + self._controller = controller + self._folder_id = folder_id + self._result = None + + @property + def id(self): + return self._id + + def run(self): + self._result = self._controller.get_task_items( + self._folder_id, SENDER_NAME) + self.refresh_finished.emit(self.id) + + def get_result(self): + return self._result + + +class TasksModel(QtGui.QStandardItemModel): + """Tasks model which cares about refresh of tasks by folder id. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(TasksModel, self).__init__() + + self._controller = controller + + self._items_by_name = {} + self._has_content = False + self._is_refreshing = False + + self._invalid_selection_item_used = False + self._invalid_selection_item = None + self._empty_tasks_item_used = False + self._empty_tasks_item = None + + self._last_folder_id = None + + self._refresh_threads = {} + self._current_refresh_thread = None + + # Initial state + self._add_invalid_selection_item() + + def clear(self): + self._items_by_name = {} + self._has_content = False + self._remove_invalid_items() + super(TasksModel, self).clear() + + def refresh(self, folder_id): + """Refresh tasks for folder. + + Args: + folder_id (Union[str, None]): Folder id. + """ + + self._refresh(folder_id) + + def get_index_by_name(self, task_name): + """Find item by name and return its index. + + Returns: + QtCore.QModelIndex: Index of item. Is invalid if task is not + found by name. + """ + + item = self._items_by_name.get(task_name) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def get_last_folder_id(self): + """Get last refreshed folder id. + + Returns: + Union[str, None]: Folder id. + """ + + return self._last_folder_id + + def _get_invalid_selection_item(self): + if self._invalid_selection_item is None: + item = QtGui.QStandardItem("Select a folder") + item.setFlags(QtCore.Qt.NoItemFlags) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + self._invalid_selection_item = item + return self._invalid_selection_item + + def _get_empty_task_item(self): + if self._empty_tasks_item is None: + item = QtGui.QStandardItem("No task") + icon = qtawesome.icon( + "fa.exclamation-circle", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_tasks_item = item + return self._empty_tasks_item + + def _add_invalid_item(self, item): + self.clear() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_invalid_item(self, item): + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _remove_invalid_items(self): + self._remove_invalid_selection_item() + self._remove_empty_task_item() + + def _add_invalid_selection_item(self): + if not self._invalid_selection_item_used: + self._add_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = True + + def _remove_invalid_selection_item(self): + if self._invalid_selection_item: + self._remove_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = False + + def _add_empty_task_item(self): + if not self._empty_tasks_item_used: + self._add_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = True + + def _remove_empty_task_item(self): + if self._empty_tasks_item_used: + self._remove_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = False + + def _refresh(self, folder_id): + self._is_refreshing = True + self._last_folder_id = folder_id + if not folder_id: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread = RefreshThread(self._controller, folder_id) + self._current_refresh_thread = thread.id + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + thread = self._refresh_threads.pop(thread_id) + if thread_id != self._current_refresh_thread: + return + + task_items = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + new_items = [] + new_names = set() + for task_item in task_items: + name = task_item.name + new_names.add(name) + item = self._items_by_name.get(name) + if item is None: + item = QtGui.QStandardItem() + item.setEditable(False) + new_items.append(item) + self._items_by_name[name] = item + + # TODO cache locally + icon = qtawesome.icon( + task_item.icon_name, + color=task_item.icon_color, + ) + item.setData(task_item.label, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + + for name in set(self._items_by_name) - new_names: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + if new_items: + root_item.appendRows(new_items) + + self._has_content = root_item.rowCount() > 0 + self._is_refreshing = False + self.refreshed.emit() + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: Model is refreshing + """ + + return self._is_refreshing + + @property + def has_content(self): + """Model has content. + + Returns: + bools: Have at least one task. + """ + + return self._has_content + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Tasks" + + return super(TasksModel, self).headerData( + section, orientation, role + ) + + +class TasksWidget(QtWidgets.QWidget): + """Tasks widget. + + Widget that handles tasks view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): Workfiles controller. + """ + + def __init__(self, controller, parent): + super(TasksWidget, self).__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setIndentation(0) + + tasks_model = TasksModel(controller) + tasks_proxy_model = QtCore.QSortFilterProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + + tasks_view.setModel(tasks_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + controller.register_event_callback( + "selection.folder.changed", + self._folder_selection_changed + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_tasks_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + self._selected_folder_id = None + + self._expected_selection_data = None + + def _clear(self): + self._tasks_model.clear() + + def _on_tasks_refresh_finished(self, event): + """Tasks were refreshed in controller. + + Ignore if refresh was triggered by tasks model, or refreshed folder is + not the same as currently selected folder. + + Args: + event (Event): Event object. + """ + + # Refresh only if current folder id is the same + if ( + event["sender"] == SENDER_NAME + or event["folder_id"] != self._selected_folder_id + ): + return + self._tasks_model.refresh(self._selected_folder_id) + + def _folder_selection_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._tasks_model.refresh(self._selected_folder_id) + + def _on_tasks_model_refresh(self): + if not self._set_expected_selection(): + self._on_selection_change() + self._tasks_proxy_model.sort(0) + + def _set_expected_selection(self): + if self._expected_selection_data is None: + return False + folder_id = self._expected_selection_data["folder_id"] + task_name = self._expected_selection_data["task_name"] + self._expected_selection_data = None + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id: + return False + if task_name is not None: + index = self._tasks_model.get_index_by_name(task_name) + if index.isValid(): + proxy_index = self._tasks_proxy_model.mapFromSource(index) + self._tasks_view.setCurrentIndex(proxy_index) + self._controller.expected_task_selected(folder_id, task_name) + return True + + def _on_expected_selection_change(self, event): + if event["task_selected"] or not event["folder_selected"]: + return + + model_folder_id = self._tasks_model.get_last_folder_id() + folder_id = event["folder_id"] + self._expected_selection_data = { + "task_name": event["task_name"], + "folder_id": folder_id, + } + + if folder_id != model_folder_id or self._tasks_model.is_refreshing: + return + self._set_expected_selection() + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + for index in selection_model.selectedIndexes(): + task_id = index.data(ITEM_ID_ROLE) + task_name = index.data(ITEM_NAME_ROLE) + parent_id = index.data(PARENT_ID_ROLE) + if task_name is not None: + return parent_id, task_id, task_name + return self._selected_folder_id, None, None + + def _on_selection_change(self): + # Don't trigger task change during refresh + # - a task was deselected if that happens + # - can cause crash triggered during tasks refreshing + if self._tasks_model.is_refreshing: + return + parent_id, task_id, task_name = self._get_selected_item_ids() + self._controller.set_selected_task(parent_id, task_id, task_name) diff --git a/openpype/tools/ayon_workfiles/widgets/utils.py b/openpype/tools/ayon_workfiles/widgets/utils.py new file mode 100644 index 0000000000..6a61239f8d --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/utils.py @@ -0,0 +1,94 @@ +from qtpy import QtWidgets, QtCore +from openpype.tools.flickcharm import FlickCharm + + +class TreeView(QtWidgets.QTreeView): + """Ultimate TreeView with flick charm and double click signals. + + Tree view have deselectable mode, which allows to deselect items by + clicking on item area without any items. + + Todos: + Add to tools utils. + """ + + double_clicked_left = QtCore.Signal() + double_clicked_right = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(TreeView, self).__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super(TreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.double_clicked_left.emit() + + elif event.button() == QtCore.Qt.RightButton: + self.double_clicked_right.emit() + + return super(TreeView, self).mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) + + +class BaseOverlayFrame(QtWidgets.QFrame): + """Base frame for overlay widgets. + + Has implemented automated resize and event filtering. + """ + + def __init__(self, parent): + super(BaseOverlayFrame, self).__init__(parent) + self.setObjectName("OverlayFrame") + + self._parent = parent + + def setVisible(self, visible): + super(BaseOverlayFrame, self).setVisible(visible) + if visible: + self._parent.installEventFilter(self) + self.resize(self._parent.size()) + else: + self._parent.removeEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Resize: + self.resize(obj.size()) + + return super(BaseOverlayFrame, self).eventFilter(obj, event) diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py new file mode 100644 index 0000000000..ef352c8b18 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/window.py @@ -0,0 +1,400 @@ +from qtpy import QtCore, QtWidgets, QtGui + +from openpype import style, resources +from openpype.tools.utils import ( + PlaceholderLineEdit, + MessageOverlayObject, +) +from openpype.tools.utils.lib import get_qta_icon_by_name_and_color + +from openpype.tools.ayon_workfiles.control import BaseWorkfileController + +from .side_panel import SidePanelWidget +from .folders_widget import FoldersWidget +from .tasks_widget import TasksWidget +from .files_widget import FilesWidget +from .utils import BaseOverlayFrame + + +# TODO move to utils +# from openpype.tools.utils.lib import ( +# get_refresh_icon, get_go_to_current_icon) +def get_refresh_icon(): + return get_qta_icon_by_name_and_color( + "fa.refresh", style.get_default_tools_icon_color() + ) + + +def get_go_to_current_icon(): + return get_qta_icon_by_name_and_color( + "fa.arrow-down", style.get_default_tools_icon_color() + ) + + +class InvalidHostOverlay(BaseOverlayFrame): + def __init__(self, parent): + super(InvalidHostOverlay, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + ( + "Workfiles tool is not supported in this host/DCCs." + "

This may be caused by a bug." + " Please contact your TD for more information." + ), + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setObjectName("OverlayFrameLabel") + + layout = QtWidgets.QVBoxLayout(self) + layout.addStretch(2) + layout.addWidget(label_widget, 0, QtCore.Qt.AlignCenter) + layout.addStretch(3) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class WorkfilesToolWindow(QtWidgets.QWidget): + """WorkFiles Window. + + Main windows of workfiles tool. + + Args: + controller (AbstractWorkfilesFrontend): Frontend controller. + parent (Optional[QtWidgets.QWidget]): Parent widget. + """ + + title = "Work Files" + + def __init__(self, controller=None, parent=None): + super(WorkfilesToolWindow, self).__init__(parent=parent) + + if controller is None: + controller = BaseWorkfileController() + + self.setWindowTitle(self.title) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + flags = self.windowFlags() | QtCore.Qt.Window + self.setWindowFlags(flags) + + self._default_window_flags = flags + + self._folder_widget = None + self._folder_filter_input = None + + self._files_widget = None + + self._first_show = True + self._controller_refreshed = False + self._context_to_set = None + # Host validation should happen only once + self._host_is_valid = None + + self._controller = controller + + # Create pages widget and set it as central widget + pages_widget = QtWidgets.QStackedWidget(self) + + home_page_widget = QtWidgets.QWidget(pages_widget) + home_body_widget = QtWidgets.QWidget(home_page_widget) + + col_1_widget = self._create_col_1_widget(controller, parent) + tasks_widget = TasksWidget(controller, home_body_widget) + col_3_widget = self._create_col_3_widget(controller, home_body_widget) + side_panel = SidePanelWidget(controller, home_body_widget) + + pages_widget.addWidget(home_page_widget) + + # Build home + home_page_layout = QtWidgets.QVBoxLayout(home_page_widget) + home_page_layout.addWidget(home_body_widget) + + # Build home - body + body_layout = QtWidgets.QVBoxLayout(home_body_widget) + split_widget = QtWidgets.QSplitter(home_body_widget) + split_widget.addWidget(col_1_widget) + split_widget.addWidget(tasks_widget) + split_widget.addWidget(col_3_widget) + split_widget.addWidget(side_panel) + split_widget.setSizes([255, 160, 455, 175]) + + body_layout.addWidget(split_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(pages_widget, 1) + + overlay_messages_widget = MessageOverlayObject(self) + overlay_invalid_host = InvalidHostOverlay(self) + overlay_invalid_host.setVisible(False) + + first_show_timer = QtCore.QTimer() + first_show_timer.setSingleShot(True) + first_show_timer.setInterval(50) + + first_show_timer.timeout.connect(self._on_first_show) + + controller.register_event_callback( + "save_as.finished", + self._on_save_as_finished, + ) + controller.register_event_callback( + "copy_representation.finished", + self._on_copy_representation_finished, + ) + controller.register_event_callback( + "workfile_duplicate.finished", + self._on_duplicate_finished + ) + controller.register_event_callback( + "open_workfile.finished", + self._on_open_finished + ) + controller.register_event_callback( + "controller.refresh.started", + self._on_controller_refresh_started, + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh_finished, + ) + + self._overlay_messages_widget = overlay_messages_widget + self._overlay_invalid_host = overlay_invalid_host + self._home_page_widget = home_page_widget + self._pages_widget = pages_widget + self._home_body_widget = home_body_widget + self._split_widget = split_widget + + self._tasks_widget = tasks_widget + self._side_panel = side_panel + + self._first_show_timer = first_show_timer + + self._post_init() + + def _post_init(self): + self._on_published_checkbox_changed() + + # Force focus on the open button by default, required for Houdini. + self._files_widget.setFocus() + + self.resize(1200, 600) + + def _create_col_1_widget(self, controller, parent): + col_widget = QtWidgets.QWidget(parent) + header_widget = QtWidgets.QWidget(col_widget) + + folder_filter_input = PlaceholderLineEdit(header_widget) + folder_filter_input.setPlaceholderText("Filter folders..") + + go_to_current_btn = QtWidgets.QPushButton(header_widget) + go_to_current_btn.setIcon(get_go_to_current_icon()) + go_to_current_btn_sp = go_to_current_btn.sizePolicy() + go_to_current_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + go_to_current_btn.setSizePolicy(go_to_current_btn_sp) + + refresh_btn = QtWidgets.QPushButton(header_widget) + refresh_btn.setIcon(get_refresh_icon()) + refresh_btn_sp = refresh_btn.sizePolicy() + refresh_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + refresh_btn.setSizePolicy(refresh_btn_sp) + + folder_widget = FoldersWidget(controller, col_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(folder_filter_input, 1) + header_layout.addWidget(go_to_current_btn, 0) + header_layout.addWidget(refresh_btn, 0) + + col_layout = QtWidgets.QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.addWidget(header_widget, 0) + col_layout.addWidget(folder_widget, 1) + + folder_filter_input.textChanged.connect(self._on_folder_filter_change) + go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) + refresh_btn.clicked.connect(self._on_refresh_clicked) + + self._folder_filter_input = folder_filter_input + self._folder_widget = folder_widget + + return col_widget + + def _create_col_3_widget(self, controller, parent): + col_widget = QtWidgets.QWidget(parent) + + header_widget = QtWidgets.QWidget(col_widget) + + files_filter_input = PlaceholderLineEdit(header_widget) + files_filter_input.setPlaceholderText("Filter files..") + + published_checkbox = QtWidgets.QCheckBox("Published", header_widget) + published_checkbox.setToolTip("Show published workfiles") + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(files_filter_input, 1) + header_layout.addWidget(published_checkbox, 0) + + files_widget = FilesWidget(controller, col_widget) + + col_layout = QtWidgets.QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.addWidget(header_widget, 0) + col_layout.addWidget(files_widget, 1) + + files_filter_input.textChanged.connect( + self._on_file_text_filter_change) + published_checkbox.stateChanged.connect( + self._on_published_checkbox_changed + ) + + self._files_filter_input = files_filter_input + self._published_checkbox = published_checkbox + + self._files_widget = files_widget + + return col_widget + + def set_window_on_top(self, on_top): + """Set window on top of other windows. + + Args: + on_top (bool): Show on top of other windows. + """ + + flags = self._default_window_flags + if on_top: + flags |= QtCore.Qt.WindowStaysOnTopHint + if self.windowFlags() != flags: + self.setWindowFlags(flags) + + def ensure_visible(self, use_context=True, save=True, on_top=False): + """Ensure the window is visible. + + This method expects arguments for compatibility with previous variant + of Workfiles tool. + + Args: + use_context (Optional[bool]): DEPRECATED: This argument is + ignored. + save (Optional[bool]): Allow to save workfiles. + on_top (Optional[bool]): Show on top of other windows. + """ + + save = True if save is None else save + on_top = False if on_top is None else on_top + + is_visible = self.isVisible() + self._controller.set_save_enabled(save) + self.set_window_on_top(on_top) + + self.show() + self.raise_() + self.activateWindow() + if is_visible: + self.refresh() + + def refresh(self): + """Trigger refresh of workfiles tool controller.""" + + self._controller.refresh() + + def showEvent(self, event): + super(WorkfilesToolWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self._first_show_timer.start() + self.setStyleSheet(style.load_stylesheet()) + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidentally perform Maya commands + whilst trying to name an instance. + """ + + pass + + def _on_first_show(self): + if not self._controller_refreshed: + self.refresh() + + def _on_file_text_filter_change(self, text): + self._files_widget.set_text_filter(text) + + def _on_published_checkbox_changed(self): + """Publish mode changed. + + Tell children widgets about it so they can handle the mode. + """ + + published_mode = self._published_checkbox.isChecked() + self._files_widget.set_published_mode(published_mode) + self._side_panel.set_published_mode(published_mode) + + def _on_folder_filter_change(self, text): + self._folder_widget.set_name_filer(text) + + def _on_go_to_current_clicked(self): + self._controller.go_to_current_context() + + def _on_refresh_clicked(self): + self.refresh() + + def _on_controller_refresh_started(self): + self._controller_refreshed = True + + def _on_controller_refresh_finished(self): + if self._host_is_valid is None: + self._host_is_valid = self._controller.is_host_valid() + self._overlay_invalid_host.setVisible(not self._host_is_valid) + + if not self._host_is_valid: + return + + def _on_save_as_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to save workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Workfile saved" + ) + + def _on_copy_representation_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to copy published workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Publish workfile saved" + ) + + def _on_duplicate_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to duplicate workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Workfile duplicated" + ) + + def _on_open_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to open workfile", + "error", + ) + else: + self.close() diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index bc4b7867c2..2ebc973a47 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -6,6 +6,8 @@ use singleton approach with global functions (using helper anyway). import os import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.host import IWorkfileHost, ILoadHost from openpype.lib import Logger from openpype.pipeline import ( @@ -46,17 +48,29 @@ class HostToolsHelper: self._log = Logger.get_logger(self.__class__.__name__) return self._log + def _init_ayon_workfiles_tool(self, parent): + from openpype.tools.ayon_workfiles.widgets import WorkfilesToolWindow + + workfiles_window = WorkfilesToolWindow(parent=parent) + self._workfiles_tool = workfiles_window + + def _init_openpype_workfiles_tool(self, parent): + from openpype.tools.workfiles.app import Window + + # Host validation + host = registered_host() + IWorkfileHost.validate_workfile_methods(host) + + workfiles_window = Window(parent=parent) + self._workfiles_tool = workfiles_window + def get_workfiles_tool(self, parent): """Create, cache and return workfiles tool window.""" if self._workfiles_tool is None: - from openpype.tools.workfiles.app import Window - - # Host validation - host = registered_host() - IWorkfileHost.validate_workfile_methods(host) - - workfiles_window = Window(parent=parent) - self._workfiles_tool = workfiles_window + if AYON_SERVER_ENABLED: + self._init_ayon_workfiles_tool(parent) + else: + self._init_openpype_workfiles_tool(parent) return self._workfiles_tool From 5ba400d89173195cadf885df3c5bc0129c84fd61 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 9 Sep 2023 03:24:54 +0000 Subject: [PATCH 360/429] [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 d5d46bab0c..b6c56296bc 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5" +__version__ = "3.16.6-nightly.1" From 5475908051c3166daa57155c18b36f672170e12b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 Sep 2023 03:25:32 +0000 Subject: [PATCH 361/429] 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 a35dbf1a17..7a39103859 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.6-nightly.1 - 3.16.5 - 3.16.5-nightly.5 - 3.16.5-nightly.4 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.4 - 3.14.9-nightly.3 - 3.14.9-nightly.2 - - 3.14.9-nightly.1 validations: required: true - type: dropdown From 81446b1bbb972bcb8ee8fa9c9df06868d7c008aa Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 11 Sep 2023 11:55:49 +0100 Subject: [PATCH 362/429] Remove hardcoded subset name for reviews --- openpype/hosts/blender/plugins/publish/collect_review.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 6459927015..3bf2e39e24 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -39,15 +39,11 @@ class CollectReview(pyblish.api.InstancePlugin): ] if not instance.data.get("remove"): - - task = instance.context.data["task"] - # Store focal length in `burninDataMembers` burninData = instance.data.setdefault("burninDataMembers", {}) burninData["focalLength"] = focal_length instance.data.update({ - "subset": f"{task}Review", "review_camera": camera, "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], From 2d255f15bedc2aca61177d80cad987b7108b5a26 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 14:05:24 +0300 Subject: [PATCH 363/429] validate empty nodes and invalid prims --- .../publish/validate_fbx_output_node.py | 109 ++++++++++++++++-- 1 file changed, 100 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index 503a3bb3c1..9f6a1b8767 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -5,7 +5,8 @@ from openpype.hosts.houdini.api.action import ( SelectInvalidAction, SelectROPAction, ) - +from openpype.hosts.houdini.api.lib import get_obj_node_output +from collections import defaultdict import hou @@ -16,27 +17,38 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): - The Output Node Path is set. - The Output Node Path refers to an existing object. - The Output Node is a Sop or Obj node. + - The Output Node has geometry data. """ order = pyblish.api.ValidatorOrder families = ["fbx"] hosts = ["houdini"] - label = "Validate Output Node" + label = "Validate FBX Output Node" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): - invalid = self.get_invalid(instance) + invalid = self.get_invalid_categorized(instance) if invalid: raise PublishValidationError( "Output node(s) are incorrect", title="Invalid output node(s)" ) - @classmethod def get_invalid(cls, instance): + out = cls.get_invalid_categorized(instance).values() + invalid = [] + for row in out: + invalid += row + return invalid + + + @classmethod + def get_invalid_categorized(cls, instance): output_node = instance.data.get("output_node") + # Check if The Output Node Path is set and + # refers to an existing object. if output_node is None: rop_node = hou.node(instance.data["instance_node"]) cls.log.error( @@ -46,11 +58,90 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): return [rop_node] - if output_node.type().category().name() not in ["Sop", "Object"]: + # Check if the Output Node is a Sop or Obj node + # also, make a dictionary of all geo obj nodes + # and their sop output node. + all_outputs = {} + # if user selects an ObjSubnet or an ObjNetwork + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + all_outputs.update({output_node : {}}) + for node in output_node.allSubChildren(): + if node.type().name() == "geo": + out = get_obj_node_output(node) + all_outputs[output_node].update({node: out}) + + # elif user selects a geometry ObjNode + elif output_node.type().name() == "geo": + out = get_obj_node_output(output_node) + all_outputs.update({output_node: out}) + + # elif user selects a SopNode + elif output_node.type().category().name() == "Sop": + # expetional case because output_node is not an obj node + all_outputs.update({output_node: output_node}) + + # Then it's wrong node type + else: cls.log.error( - "Output node %s is not a SOP or OBJ node. " - "It must point to a SOP or OBJ node, " - "instead found category type: %s" - % (output_node.path(), output_node.type().category().name()) + "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " + "Instead found category type: %s %s" + , output_node.path(), output_node.type().category().name() + , output_node.type().name() ) return [output_node] + + # Check if geo obj node have geometry. + # return geo obj node if their sop output node + valid = {} + invalid = defaultdict(list) + cls.filter_inner_dict(all_outputs, valid, invalid) + + invalid_prim_types = ["VDB", "Volume"] + for obj_node, sop_node in valid.items(): + # Empty Geometry test + if not hasattr(sop_node, "geometry"): + invalid["empty_geometry"].append(sop_node) + cls.log.error( + "Sop node '%s' includes no geometry." + , sop_node.path() + ) + continue + + frame = instance.data.get("frameStart", 0) + geo = sop_node.geometryAtFrame(frame) + if len(geo.iterPrims()) == 0: + invalid["empty_geometry"].append(sop_node) + cls.log.error( + "Sop node '%s' includes no geometry." + , sop_node.path() + ) + continue + + # Invalid Prims test + for prim_type in invalid_prim_types: + if geo.countPrimType(prim_type) > 0: + invalid["invalid_prims"].append(sop_node) + cls.log.error( + "Sop node '%s' includes invliad prims of type '%s'." + , sop_node.path(), prim_type + ) + + if invalid: + return invalid + + @classmethod + def filter_inner_dict(cls, d: dict, valid: dict, invalid: dict): + """Parse the dictionary and filter items to valid and invalid. + + Invalid items have empty values like {}, None + Valid dictionary is a flattened dictionary that includes + the valid inner items. + """ + + for k, v in d.items(): + if not v: + invalid["empty_objs"].append(k) + elif isinstance(v, dict): + cls.filter_inner_dict(v, valid, invalid) + else: + valid.update({k:v}) From ad0c6245cdd347fed47b075e1ecb13e5dfb8c359 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 15:52:11 +0300 Subject: [PATCH 364/429] remove unnecessary logic --- .../publish/validate_fbx_output_node.py | 89 +++++++++---------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index 9f6a1b8767..d06ef593d3 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -28,23 +28,15 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): def process(self, instance): - invalid = self.get_invalid_categorized(instance) + invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Output node(s) are incorrect", title="Invalid output node(s)" ) + @classmethod def get_invalid(cls, instance): - out = cls.get_invalid_categorized(instance).values() - invalid = [] - for row in out: - invalid += row - return invalid - - - @classmethod - def get_invalid_categorized(cls, instance): output_node = instance.data.get("output_node") # Check if The Output Node Path is set and @@ -58,27 +50,47 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): return [rop_node] - # Check if the Output Node is a Sop or Obj node - # also, make a dictionary of all geo obj nodes - # and their sop output node. - all_outputs = {} - # if user selects an ObjSubnet or an ObjNetwork + # Check if the Output Node is a Sop or an Obj node + # also, list all sop output nodes inside as well as + # invalid empty nodes. + all_out_sops = [] + invalid = defaultdict(list) + + # if output_node is an ObjSubnet or an ObjNetwork if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - all_outputs.update({output_node : {}}) for node in output_node.allSubChildren(): if node.type().name() == "geo": out = get_obj_node_output(node) - all_outputs[output_node].update({node: out}) + if out: + all_out_sops.append(out) + else: + invalid["empty_objs"].append(node) + cls.log.error( + "Geo Obj Node '%s' is empty!" + , node.path() + ) + if not all_out_sops: + invalid["empty_objs"].append(output_node) + cls.log.error( + "Output Node '%s' is empty!" + , node.path() + ) - # elif user selects a geometry ObjNode + # elif output_node is an ObjNode elif output_node.type().name() == "geo": out = get_obj_node_output(output_node) - all_outputs.update({output_node: out}) + if out: + all_out_sops.append(out) + else: + invalid["empty_objs"].append(node) + cls.log.error( + "Output Node '%s' is empty!" + , node.path() + ) - # elif user selects a SopNode + # elif output_node is a SopNode elif output_node.type().category().name() == "Sop": - # expetional case because output_node is not an obj node - all_outputs.update({output_node: output_node}) + all_out_sops.append(output_node) # Then it's wrong node type else: @@ -90,19 +102,15 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): ) return [output_node] - # Check if geo obj node have geometry. - # return geo obj node if their sop output node - valid = {} - invalid = defaultdict(list) - cls.filter_inner_dict(all_outputs, valid, invalid) - + # Check if all output sop nodes have geometry + # and don't contain invalid prims invalid_prim_types = ["VDB", "Volume"] - for obj_node, sop_node in valid.items(): + for sop_node in all_out_sops: # Empty Geometry test if not hasattr(sop_node, "geometry"): invalid["empty_geometry"].append(sop_node) cls.log.error( - "Sop node '%s' includes no geometry." + "Sop node '%s' doesn't include any prims." , sop_node.path() ) continue @@ -112,7 +120,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if len(geo.iterPrims()) == 0: invalid["empty_geometry"].append(sop_node) cls.log.error( - "Sop node '%s' includes no geometry." + "Sop node '%s' doesn't include any prims." , sop_node.path() ) continue @@ -127,21 +135,4 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): ) if invalid: - return invalid - - @classmethod - def filter_inner_dict(cls, d: dict, valid: dict, invalid: dict): - """Parse the dictionary and filter items to valid and invalid. - - Invalid items have empty values like {}, None - Valid dictionary is a flattened dictionary that includes - the valid inner items. - """ - - for k, v in d.items(): - if not v: - invalid["empty_objs"].append(k) - elif isinstance(v, dict): - cls.filter_inner_dict(v, valid, invalid) - else: - valid.update({k:v}) + return [output_node] From 20f4b62213530118829dbbe027d25c982f80675b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:33:28 +0300 Subject: [PATCH 365/429] Remove unnecessary line --- .../hosts/houdini/plugins/publish/validate_mesh_is_static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 4d0904eb53..6bf94f7536 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -30,7 +30,7 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin, invalid = self.get_invalid(instance) if invalid: - nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) From 21c174c47bd761392ff6856346bdedf5ece732d9 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:34:05 +0300 Subject: [PATCH 366/429] update error message --- .../publish/validate_fbx_output_node.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index d06ef593d3..d493092755 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -6,7 +6,6 @@ from openpype.hosts.houdini.api.action import ( SelectROPAction, ) from openpype.hosts.houdini.api.lib import get_obj_node_output -from collections import defaultdict import hou @@ -30,8 +29,10 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: + nodes = [n.path() for n in invalid] raise PublishValidationError( - "Output node(s) are incorrect", + "See log for details. " + "Invalid nodes: {0}".format(nodes), title="Invalid output node(s)" ) @@ -54,7 +55,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): # also, list all sop output nodes inside as well as # invalid empty nodes. all_out_sops = [] - invalid = defaultdict(list) + invalid = [] # if output_node is an ObjSubnet or an ObjNetwork if output_node.childTypeCategory() == hou.objNodeTypeCategory(): @@ -64,13 +65,13 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if out: all_out_sops.append(out) else: - invalid["empty_objs"].append(node) + invalid.append(node) # empty_objs cls.log.error( "Geo Obj Node '%s' is empty!" , node.path() ) if not all_out_sops: - invalid["empty_objs"].append(output_node) + invalid.append(output_node) # empty_objs cls.log.error( "Output Node '%s' is empty!" , node.path() @@ -82,7 +83,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if out: all_out_sops.append(out) else: - invalid["empty_objs"].append(node) + invalid.append(node) # empty_objs cls.log.error( "Output Node '%s' is empty!" , node.path() @@ -92,7 +93,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): elif output_node.type().category().name() == "Sop": all_out_sops.append(output_node) - # Then it's wrong node type + # Then it's a wrong node type else: cls.log.error( "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " @@ -108,7 +109,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): for sop_node in all_out_sops: # Empty Geometry test if not hasattr(sop_node, "geometry"): - invalid["empty_geometry"].append(sop_node) + invalid.append(sop_node) # empty_geometry cls.log.error( "Sop node '%s' doesn't include any prims." , sop_node.path() @@ -118,7 +119,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): frame = instance.data.get("frameStart", 0) geo = sop_node.geometryAtFrame(frame) if len(geo.iterPrims()) == 0: - invalid["empty_geometry"].append(sop_node) + invalid.append(sop_node) # empty_geometry cls.log.error( "Sop node '%s' doesn't include any prims." , sop_node.path() @@ -128,11 +129,11 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): # Invalid Prims test for prim_type in invalid_prim_types: if geo.countPrimType(prim_type) > 0: - invalid["invalid_prims"].append(sop_node) + invalid.append(sop_node) # invalid_prims cls.log.error( - "Sop node '%s' includes invliad prims of type '%s'." + "Sop node '%s' includes invalid prims of type '%s'." , sop_node.path(), prim_type ) if invalid: - return [output_node] + return invalid From 9105e74b4326686510536cc9e2f8fd37f44be563 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:43:26 +0300 Subject: [PATCH 367/429] resolve hound --- .../publish/validate_fbx_output_node.py | 32 +++++++++---------- .../publish/validate_mesh_is_static.py | 2 -- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index d493092755..ea13d25122 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -67,14 +67,14 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): else: invalid.append(node) # empty_objs cls.log.error( - "Geo Obj Node '%s' is empty!" - , node.path() + "Geo Obj Node '%s' is empty!", + node.path() ) if not all_out_sops: invalid.append(output_node) # empty_objs cls.log.error( - "Output Node '%s' is empty!" - , node.path() + "Output Node '%s' is empty!", + node.path() ) # elif output_node is an ObjNode @@ -85,21 +85,21 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): else: invalid.append(node) # empty_objs cls.log.error( - "Output Node '%s' is empty!" - , node.path() + "Output Node '%s' is empty!", + node.path() ) # elif output_node is a SopNode elif output_node.type().category().name() == "Sop": - all_out_sops.append(output_node) + all_out_sops.append(output_node) # Then it's a wrong node type else: cls.log.error( "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " - "Instead found category type: %s %s" - , output_node.path(), output_node.type().category().name() - , output_node.type().name() + "Instead found category type: %s %s", + output_node.path(), output_node.type().category().name(), + output_node.type().name() ) return [output_node] @@ -111,8 +111,8 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if not hasattr(sop_node, "geometry"): invalid.append(sop_node) # empty_geometry cls.log.error( - "Sop node '%s' doesn't include any prims." - , sop_node.path() + "Sop node '%s' doesn't include any prims.", + sop_node.path() ) continue @@ -121,8 +121,8 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if len(geo.iterPrims()) == 0: invalid.append(sop_node) # empty_geometry cls.log.error( - "Sop node '%s' doesn't include any prims." - , sop_node.path() + "Sop node '%s' doesn't include any prims.", + sop_node.path() ) continue @@ -131,8 +131,8 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if geo.countPrimType(prim_type) > 0: invalid.append(sop_node) # invalid_prims cls.log.error( - "Sop node '%s' includes invalid prims of type '%s'." - , sop_node.path(), prim_type + "Sop node '%s' includes invalid prims of type '%s'.", + sop_node.path(), prim_type ) if invalid: diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 6bf94f7536..b499682e0b 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -10,8 +10,6 @@ from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction from openpype.hosts.houdini.api.lib import get_output_children -import hou - class ValidateMeshIsStatic(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): From 583cfba38c2f4ffbf0c72795ac4adb48d98715e5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:48:38 +0300 Subject: [PATCH 368/429] update doc string --- .../hosts/houdini/plugins/publish/validate_fbx_output_node.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index ea13d25122..894dad7d72 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -17,6 +17,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): - The Output Node Path refers to an existing object. - The Output Node is a Sop or Obj node. - The Output Node has geometry data. + - The Output Node doesn't include invalid primitive types. """ order = pyblish.api.ValidatorOrder From 1365bec5188cfb810ee73b442e9393bf918c2ab9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 11 Sep 2023 16:19:15 +0200 Subject: [PATCH 369/429] :recycle: add check for produced file --- openpype/hosts/max/plugins/publish/extract_model_obj.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py index e522b1e7a1..a5d9ad6597 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_obj.py +++ b/openpype/hosts/max/plugins/publish/extract_model_obj.py @@ -3,6 +3,7 @@ import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection +from openpype.pipeline.publish import KnownPublishError class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): @@ -27,6 +28,7 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): filepath = os.path.join(stagingdir, filename) self.log.info("Writing OBJ '%s' to '%s'" % (filepath, stagingdir)) + self.log.info("Performing Extraction ...") with maintained_selection(): # select and export node_list = instance.data["members"] @@ -38,7 +40,10 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): using=rt.ObjExp, ) - self.log.info("Performing Extraction ...") + if not os.path.exists(filepath): + raise KnownPublishError( + "File {} wasn't produced by 3ds max, please check the logs.") + if "representations" not in instance.data: instance.data["representations"] = [] From 86629c85cab0cb59585f0fd0d6120a2c0c27f7fd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 11 Sep 2023 16:21:35 +0200 Subject: [PATCH 370/429] :recycle: make usd plugin validator optional this is temporary hack to allow testing and publishing models without USD plugin in max as the plugin validator will run always, no matter if the USD extractor is enabled or not. This will change when #5602 is implemented. --- .../max/plugins/publish/validate_usd_plugin.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py index 9957e62736..36c4291925 100644 --- a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py @@ -1,9 +1,13 @@ # -*- coding: utf-8 -*- """Validator for USD plugin.""" -from openpype.pipeline import PublishValidationError from pyblish.api import InstancePlugin, ValidatorOrder from pymxs import runtime as rt +from openpype.pipeline import ( + OptionalPyblishPluginMixin, + PublishValidationError +) + def get_plugins() -> list: """Get plugin list from 3ds max.""" @@ -17,17 +21,25 @@ def get_plugins() -> list: return plugin_info_list -class ValidateUSDPlugin(InstancePlugin): +class ValidateUSDPlugin(OptionalPyblishPluginMixin, + InstancePlugin): """Validates if USD plugin is installed or loaded in 3ds max.""" order = ValidatorOrder - 0.01 families = ["model"] hosts = ["max"] - label = "USD Plugin" + label = "Validate USD Plugin loaded" + optional = True def process(self, instance): """Plugin entry point.""" + for sc in ValidateUSDPlugin.__subclasses__(): + self.log.info(sc) + + if not self.is_active(instance.data): + return + plugin_info = get_plugins() usd_import = "usdimport.dli" if usd_import not in plugin_info: From ae02fe220aefb292f705f715fea4def1b2192782 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 11 Sep 2023 16:28:11 +0200 Subject: [PATCH 371/429] AfterEffects: fix imports of image sequences (#5581) * Fix loading image sequence in AE * Fix logic Files might be list or str * Update openpype/hosts/aftereffects/plugins/load/load_file.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/aftereffects/plugins/load/load_file.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/load/load_file.py b/openpype/hosts/aftereffects/plugins/load/load_file.py index def7c927ab..8d52aac546 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_file.py +++ b/openpype/hosts/aftereffects/plugins/load/load_file.py @@ -31,13 +31,8 @@ class FileLoader(api.AfterEffectsLoader): path = self.filepath_from_context(context) - repr_cont = context["representation"]["context"] - if "#" not in path: - frame = repr_cont.get("frame") - if frame: - padding = len(frame) - path = path.replace(frame, "#" * padding) - import_options['sequence'] = True + if len(context["representation"]["files"]) > 1: + import_options['sequence'] = True if not path: repr_id = context["representation"]["_id"] From 1fdbe05905995641f02b4c4c3b2d7e27be6ecf3a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 11 Sep 2023 17:21:38 +0200 Subject: [PATCH 372/429] Photoshop: fixed blank Flatten image (#5600) * OP-6763 - refresh all visible for Flatten image Previously newly added layers were missing. * OP-6763 - added explicit image collector Creator was adding 'layer' metadata from workfile only during collect_instances, it was missing for newly added layers. This should be cleaner approach * OP-6763 - removed unnecessary method overwrite Creator is not adding layer to instance, separate collector created. * OP-6763 - cleanup of names Was failing when template for subset name for image family contained {layer} * OP-6763 - cleanup, removed adding layer metadata Separate collector created, cleaner. Fixed propagation of mark_for_review * OP-6763 - using members instead of layer data Members should be more reliable. * OP-6763 - updated image from Settings Explicit subset name template was removed some time ago as confusing. * OP-6763 - added explicit local plugin Automated plugin has different logic, local would need to handle if auto_image is disabled by artist * OP-6763 - Hound * OP-6345 - fix - review for image family Image family instance contained flattened content. Now it reuses previously extracted file without need to re-extract. --- .../plugins/create/create_flatten_image.py | 40 +++++++++--- .../photoshop/plugins/create/create_image.py | 17 ++--- .../publish/collect_auto_image_refresh.py | 24 +++++++ .../plugins/publish/collect_image.py | 20 ++++++ .../plugins/publish/extract_image.py | 8 ++- .../plugins/publish/extract_review.py | 59 +++++++++++++++--- website/docs/admin_hosts_photoshop.md | 7 +-- .../assets/admin_hosts_photoshop_settings.png | Bin 14364 -> 16718 bytes 8 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_image.py diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index 9d4189a1a3..e4229788bd 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -4,6 +4,7 @@ from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api from openpype.hosts.photoshop.lib import PSAutoCreator from openpype.pipeline.create import get_subset_name +from openpype.lib import prepare_template_data from openpype.client import get_asset_by_name @@ -37,19 +38,14 @@ class AutoImageCreator(PSAutoCreator): asset_doc = get_asset_by_name(project_name, asset_name) if existing_instance is None: - subset_name = get_subset_name( - self.family, self.default_variant, task_name, asset_doc, + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, project_name, host_name ) - publishable_ids = [layer.id for layer in api.stub().get_layers() - if layer.visible] data = { "asset": asset_name, "task": task_name, - # ids are "virtual" layers, won't get grouped as 'members' do - # same difference in color coded layers in WP - "ids": publishable_ids } if not self.active_on_create: @@ -69,8 +65,8 @@ class AutoImageCreator(PSAutoCreator): existing_instance["asset"] != asset_name or existing_instance["task"] != task_name ): - subset_name = get_subset_name( - self.family, self.default_variant, task_name, asset_doc, + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, project_name, host_name ) @@ -118,3 +114,29 @@ class AutoImageCreator(PSAutoCreator): Artist might disable this instance from publishing or from creating review for it though. """ + + def get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + dynamic_data = prepare_template_data({"layer": "{layer}"}) + subset_name = get_subset_name( + self.family, variant, task_name, asset_doc, + project_name, host_name, dynamic_data=dynamic_data + ) + return self._clean_subset_name(subset_name) + + def _clean_subset_name(self, subset_name): + """Clean all variants leftover {layer} from subset name.""" + dynamic_data = prepare_template_data({"layer": "{layer}"}) + for value in dynamic_data.values(): + if value in subset_name: + return (subset_name.replace(value, "") + .replace("__", "_") + .replace("..", ".")) + return subset_name diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 8d3ac9f459..af20d456e0 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -94,12 +94,17 @@ class ImageCreator(Creator): name = self._clean_highlights(stub, directory) layer_names_in_hierarchy.append(name) - data.update({"subset": subset_name}) - data.update({"members": [str(group.id)]}) - data.update({"layer_name": layer_name}) - data.update({"long_name": "_".join(layer_names_in_hierarchy)}) + data_update = { + "subset": subset_name, + "members": [str(group.id)], + "layer_name": layer_name, + "long_name": "_".join(layer_names_in_hierarchy) + } + data.update(data_update) - creator_attributes = {"mark_for_review": self.mark_for_review} + mark_for_review = (pre_create_data.get("mark_for_review") or + self.mark_for_review) + creator_attributes = {"mark_for_review": mark_for_review} data.update({"creator_attributes": creator_attributes}) if not self.active_on_create: @@ -124,8 +129,6 @@ class ImageCreator(Creator): if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) - layer = api.stub().get_layer(instance_data["members"][0]) - instance_data["layer"] = layer instance = CreatedInstance.from_existing( instance_data, self ) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py new file mode 100644 index 0000000000..741fb0e9cd --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py @@ -0,0 +1,24 @@ +import pyblish.api + +from openpype.hosts.photoshop import api as photoshop + + +class CollectAutoImageRefresh(pyblish.api.ContextPlugin): + """Refreshes auto_image instance with currently visible layers.. + """ + + label = "Collect Auto Image Refresh" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.2 + + def process(self, context): + for instance in context: + creator_identifier = instance.data.get("creator_identifier") + if creator_identifier and creator_identifier == "auto_image": + self.log.debug("Auto image instance found, won't create new") + # refresh existing auto image instance with current visible + publishable_ids = [layer.id for layer in photoshop.stub().get_layers() # noqa + if layer.visible] + instance.data["ids"] = publishable_ids + return diff --git a/openpype/hosts/photoshop/plugins/publish/collect_image.py b/openpype/hosts/photoshop/plugins/publish/collect_image.py new file mode 100644 index 0000000000..64727cef33 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_image.py @@ -0,0 +1,20 @@ +import pyblish.api + +from openpype.hosts.photoshop import api + + +class CollectImage(pyblish.api.InstancePlugin): + """Collect layer metadata into a instance. + + Used later in validation + """ + order = pyblish.api.CollectorOrder + 0.200 + label = 'Collect Image' + + hosts = ["photoshop"] + families = ["image"] + + def process(self, instance): + if instance.data.get("members"): + layer = api.stub().get_layer(instance.data["members"][0]) + instance.data["layer"] = layer diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index cdb28c742d..680f580cc0 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -45,9 +45,11 @@ class ExtractImage(pyblish.api.ContextPlugin): # Perform extraction files = {} ids = set() - layer = instance.data.get("layer") - if layer: - ids.add(layer.id) + # real layers and groups + members = instance.data("members") + if members: + ids.update(set([int(member) for member in members])) + # virtual groups collected by color coding or auto_image add_ids = instance.data.pop("ids", None) if add_ids: ids.update(set(add_ids)) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 4aa7a05bd1..afddbdba31 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -1,4 +1,5 @@ import os +import shutil from PIL import Image from openpype.lib import ( @@ -55,6 +56,7 @@ class ExtractReview(publish.Extractor): } if instance.data["family"] != "review": + self.log.debug("Existing extracted file from image family used.") # enable creation of review, without this jpg review would clash # with jpg of the image family output_name = repre_name @@ -62,8 +64,15 @@ class ExtractReview(publish.Extractor): repre_skeleton.update({"name": repre_name, "outputName": output_name}) - if self.make_image_sequence and len(layers) > 1: - self.log.info("Extract layers to image sequence.") + img_file = self.output_seq_filename % 0 + self._prepare_file_for_image_family(img_file, instance, + staging_dir) + repre_skeleton.update({ + "files": img_file, + }) + processed_img_names = [img_file] + elif self.make_image_sequence and len(layers) > 1: + self.log.debug("Extract layers to image sequence.") img_list = self._save_sequence_images(staging_dir, layers) repre_skeleton.update({ @@ -72,17 +81,17 @@ class ExtractReview(publish.Extractor): "fps": fps, "files": img_list, }) - instance.data["representations"].append(repre_skeleton) processed_img_names = img_list else: - self.log.info("Extract layers to flatten image.") - img_list = self._save_flatten_image(staging_dir, layers) + self.log.debug("Extract layers to flatten image.") + img_file = self._save_flatten_image(staging_dir, layers) repre_skeleton.update({ - "files": img_list, + "files": img_file, }) - instance.data["representations"].append(repre_skeleton) - processed_img_names = [img_list] + processed_img_names = [img_file] + + instance.data["representations"].append(repre_skeleton) ffmpeg_args = get_ffmpeg_tool_args("ffmpeg") @@ -111,6 +120,35 @@ class ExtractReview(publish.Extractor): self.log.info(f"Extracted {instance} to {staging_dir}") + def _prepare_file_for_image_family(self, img_file, instance, staging_dir): + """Converts existing file for image family to .jpg + + Image instance could have its own separate review (instance per layer + for example). This uses extracted file instead of extracting again. + Args: + img_file (str): name of output file (with 0000 value for ffmpeg + later) + instance: + staging_dir (str): temporary folder where extracted file is located + """ + repre_file = instance.data["representations"][0] + source_file_path = os.path.join(repre_file["stagingDir"], + repre_file["files"]) + if not os.path.exists(source_file_path): + raise RuntimeError(f"{source_file_path} doesn't exist for " + "review to create from") + _, ext = os.path.splitext(repre_file["files"]) + if ext != ".jpg": + im = Image.open(source_file_path) + # without this it produces messy low quality jpg + rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") + rgb_im.alpha_composite(im) + rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + else: + # handles already .jpg + shutil.copy(source_file_path, + os.path.join(staging_dir, img_file)) + def _generate_mov(self, ffmpeg_path, instance, fps, no_of_frames, source_files_pattern, staging_dir): """Generates .mov to upload to Ftrack. @@ -218,6 +256,11 @@ class ExtractReview(publish.Extractor): (list) of PSItem """ layers = [] + # creating review for existing 'image' instance + if instance.data["family"] == "image" and instance.data.get("layer"): + layers.append(instance.data["layer"]) + return layers + for image_instance in instance.context: if image_instance.data["family"] != "image": continue diff --git a/website/docs/admin_hosts_photoshop.md b/website/docs/admin_hosts_photoshop.md index de684f01d2..d79789760e 100644 --- a/website/docs/admin_hosts_photoshop.md +++ b/website/docs/admin_hosts_photoshop.md @@ -33,7 +33,6 @@ Provides list of [variants](artist_concepts.md#variant) that will be shown to an Provides simplified publishing process. It will create single `image` instance for artist automatically. This instance will produce flatten image from all visible layers in a workfile. -- Subset template for flatten image - provide template for subset name for this instance (example `imageBeauty`) - Review - should be separate review created for this instance ### Create Review @@ -111,11 +110,11 @@ Set Byte limit for review file. Applicable if gigantic `image` instances are pro #### Extract jpg Options -Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. +Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. #### Extract mov Options -Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. +Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. ### Workfile Builder @@ -124,4 +123,4 @@ Allows to open prepared workfile for an artist when no workfile exists. Useful t Could be configured per `Task type`, eg. `composition` task type could use different `.psd` template file than `art` task. Workfile template must be accessible for all artists. -(Currently not handled by [SiteSync](module_site_sync.md)) \ No newline at end of file +(Currently not handled by [SiteSync](module_site_sync.md)) diff --git a/website/docs/assets/admin_hosts_photoshop_settings.png b/website/docs/assets/admin_hosts_photoshop_settings.png index aaa6ecbed7b353733f8424abe3c2df0cb903935a..9478fbedf785ca8ae66132a14cb400c38e2dccc9 100644 GIT binary patch literal 16718 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 literal 14364 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 Date: Mon, 11 Sep 2023 17:23:08 +0200 Subject: [PATCH 373/429] AfterEffects: added validator for missing files in FootageItems (#5590) * OP-6345 - updated logic to return path and comp for FootageItem Used later to check existance of file in published comps * OP-6345 - added validator if footage files exist Comp could contain multiple FootageItems, eg imported file(s). If file is missing render triggered by jsx fails silently. * OP-6345 - updated extension * OP-6345 - small updates after testing * OP-6345 - fix - handle Solid Footage items JSX failed silently on Solid item as it doesn't have any `.file` * OP-6345 - enhance documentation * OP-6345 - remove optionality This plugin shouldn't be optional as when needed and skipped it result in really weird behavior. * OP-6345 - updated documentation Added missing plugins. * OP-6345 - missed functionality for optionality * OP-6345 - removed unneeded import --- openpype/hosts/aftereffects/api/extension.zxp | Bin 102930 -> 103005 bytes .../api/extension/CSXS/manifest.xml | 24 ++++----- .../api/extension/jsx/hostscript.jsx | 16 +++++- openpype/hosts/aftereffects/api/ws_stub.py | 8 ++- .../publish/help/validate_footage_items.xml | 14 +++++ .../plugins/publish/validate_footage_items.py | 49 ++++++++++++++++++ website/docs/admin_hosts_aftereffects.md | 8 +++ 7 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml create mode 100644 openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py diff --git a/openpype/hosts/aftereffects/api/extension.zxp b/openpype/hosts/aftereffects/api/extension.zxp index 358e9740d3c2479b88dff08af47eba3d74be63ce..933dc7dc6cab34b6a1630b25d11a2167fa0aef7a 100644 GIT binary patch delta 10917 zcmZvC1ymi)((O68y9E!yodkDxceh}{-QnQw2ROL91q<#32yVe4KyZiP{*v7P{`Y?G zy_q%BHPuyJwYsNg?KQQNis06Y;1a^26G&g{q9%cVTChG;`k%Jp4oV&Eub_m91@%`j z!xlq@2?}7)0#}l|zf&iDHT!L6He+CErYr{yg988n5CDI=7i{LcxS#<5Td??B572+x zjcY2nEVE(=(0sT@ubAwGN|F~GOvn#pA4~fRjW{k}3O|QXHJ_?4gEnQfj{DMsfxTZ% zOmK8^y3%ZO66DezA$lcQY#P*D6=u+kvD1~tDrlp_GI{%j;i$>a`S=Pi!+DFHJWHW` z#wrWmAU&_|g=Z5CO1Pn68pTg!G9B46mp@UQj6*ElPGmnA zYLIBHkAe9q4yC0m*Gky?DORTU-ADr?y6C|eYKq`QsV-_KNci&IR#XiBj9CfBh2Pt8 zA`xA35@qq9+WE!OQ&|D;_@x_XERN|TKfdLtn5)?D6t>ET!qi&Pno(YMINNknr@ztL zTCK_DtT}n`P`g5VQ3y(Gj?W7!APb9}NwRv2EtAz*03E!AD$u+ zus18qd_Qqx4mzidB}lnSko4*=b%s+D{pf(0U0zbpm91)28AVC;qIl-tAVvOi#S6lm zI}8`NEneXhf0{Z;9Y(!1uOT^62c5Y~qEAse2}k)X5OsA+Yw4i|_4XAH2Ng2-V$J-_ zg5Fn@m)GiJ%``+@AL$)o??ru8 z!CI58AQNG)8ULVN&j3-8-L#c1WlR+&2oK=8PDr9`X(D+XYAq}mtwrhJa``SrA#WtC zt~eeRdBRG0!19YOd<|)NCC0Z8bFYn$}F*m}0}3D0j{yYKbjZhG(%w6An#+K(N=SU|cyEN%$6~e;c!< z8$7;2yoN8o{h5Br<_QpFecxK#7ILMI!Y~m&*1MySbkbmoUaVJTs*~dTt2;X21r|&e zMhK>%a`?MT)2VX)?nz#1HLSlv0=M43g&9n6kN)3H-5_f~|1YhF!z3mQFbYEpEF0(r z&$hq%XYV$Q1WrT$OPd?&`fn|mFf8G3jOwtgzeCH1*U*VBo@@krl zL!aK8gqN}`xuz>rP)??$?Txa-5muO|*L7!QcPtfjbv;wlN13SnqGR#m+%%;vkn=}c z2>7 zz|C-Hu3|%s`22dfaZ(WUh#IUk>f)}fw)RnG0DmlC13qoCjE;Skq&C^I!5Xc*(u*SO zmvy(jz_?sb{bzE2uo>QRSV@^TTZRy;1GfQIsWHtsq^6Vt1g)VCB|vJ$#jH zfzt>F>D=>K*m>5k_qDHjarc?+Q$jeDYQkJjE1G`hQv?Bo{Cty4WWN8#MUo)LFI)cP7r4q`)=s9CFD zky?JplJdL0n4LV(+_1ryzo>N`Rm8DhPESS+;Q@S{@==U%0N+}vr_*DQ4Mk$4^bqSr zD>u_6REUL}Z+1jQX*;1b%yU5*RzRWeU6s%+a* zhEwj|$9C!QyiQ5I{XK*+3aimZDN$Z zm_OJ}^`N91@n`{C6N>{h&1?-VK;s9ERiq+Uy^%LEvyK0wL3|Xe=2OxyaF8Xr!5lxO z?wxXN&S9Ps00q#FFm)|%tV{bM5Uz=}^HQxEf3+vw*SLQRjdY9QZ}7|V-V9L@x{kBT zJNA?c>w{IeGZ`j(G;YhM8ks@Fw*V>nFwfCxa?gIeB~<9X60u`YcemF88N}T1hS$M) zKz#$4$tZWuRpE-;WXQ~t!!mn?_7pKlp#;IT3IP?O91tfJN_3Lp45aFHLbh2C-8&pRqlPJygpAP>*Gt0B=2!I+OgT_+V* z$I6oJ42oRbEd)&v3em$&g#IL(C74#hYpiBhT5Y%xDU7;PgH1{Z2B)oz;#`zUl`y_H zXl<7w(cdIFpC!}eRfy$2={B>I^Gk<5L)n%{@5=sp*UF5e2$vAAmtUK||F6TP3%yy@ z;p~OTES7EB@g>r&4>zsQ7B=ac*#b8&=X32JPq3D$i%+pYRZ{SeM~m;98~T6lnAVw^ z*$xC4P;DGStI7FmP~$GrDQ6ioS|d1X84Ral!(jWv6l!6=+{G*P#QJwU3cN%M!5t5i z#i(p<%));IXE`FQe$S3EEfa%xY0ptH^-U^fn3u3{RoQND`z{%a0ka^*bw;)s_RQs?N1o`Jn0U4MmLh@3W=%AmI2jI}O zm$SEPiK*u*tzg$&->&BId_}&pjX=^yxHk~j<2;RhYYJ_mv4u}O-5=CY?%4Ak!%x_4 z#fCn8quG!7?A;zGOAKk1b8Tf0&QILoux1q6#{NfgkQA99y;?L-ABu}qY--RS0WmG^ zps5+#NUj-iCw#lGI9#Qx`Me92c_||>PIsC2aquLM65p)IEc~lYk1p)DBkX`@(w&H? z;&x1UAozYq16kG+L#Pm+NZUZ!u&8VfGmF zY$tG5Aq*sD^vtwkfgF~lBh`KlwXjK z+^cCdTK6UdvOd!e_c#y?hX(#^JbSPn)PW65ei@f@*v)39if-#_q@B2*EiEa&yf2yM zbu$qiGj*m-G-!2kDeZ#VB4>ONmd1W?!t%%Q8Nal^amDUw#L2_|K!(q>gWQ_TVoax} z0IG)d-3;Cw%2jD)XM`bR7NqZ)$()iBN3NY(6TQrw!^TigudM_3>K@f zx%mtey`sij1_7cSSi0DRLoXR7^}!+tBS<(vX8<;XGr63x`>>EX>iGI&2a&tDN?4xb zdVNT9ur-8s5%#%l)3Zyv0c z!k`yJf+xn8!H!S^6Qa)i=tc|2rppny=`ccFP8*K}qtk2}qU<*4YDv;4zbSb99kh`) znRIM`x5qN#t8>NcJV7V)@n`={2T8G4)Y~D~)&{~r^cOspdSOu<>ZS2WN)$DpNB@t$ z{}Mq&_mKIi;bj-Dytr-YW4IW%tX;!A#ONDH50!)a z0k)=L5yst!UM^%5Ik{O`WL@0;7$6d)Au*ZW&6v0!IaxWRLPVXsPm^2CmgpAo;b-}j zIb8zxqZiZn!QWK5`-&Qug%q^3&T}Cx8pJpQJ=&CPgcWbJVfjiZ3-LGSsD*zX!$U@? zUrAlGloroG>qYO6*UtPq)<#{VB~v00ZPE$tkO|#|nta|!+7(#a9xK9M(||ZgH1FT& zAUm&N>oF|(L$DD84u;9UJJ50~hq#$8zf%qGef=)02wxh}$hp*NcbC3{OYqqo5B*u4 zWP;%R#4asc63T{%9m;Lzp0~?E{A1a(p)qREM+@8BZ{)MwA-V8|U1TOM8%ytzbZ1cvlFJ^CR9Yx5Nu{eyvo}tl%2pf$To&bUWNBmChXG zg^0k9rAKSgwQ%i>6!dw7g4RS>y%|O^P=9oGZq(A-vkCLM-{=?Twb>hr(-auNT$h;F z9^hzpO!gmlIp?_PSr<=~# z8G%N3%;mk2+=@jxY;>zSac7IB(=7$-0{crU0j~LcpVGBl)zvbRiuM+DJ(1kQP3tYr zUUmH+lxa1=HoAzETk>xh5OJz7DOxp?uDKErq2DvP(j_}=K_QBhsr3Zfrb%LNekmLJ zY$keKo@_;C%)~j_(b)s~q|XH>&Re z25zVzswFMTC=Q~?I72RSMsCsG+eb&89%*TGvfc5gd_eDIdb0eE(s)p@>k~YCj9M0) zR7hG_ykFG|h@(yU@d4E1K; z;x_6doDApqXy|HD%;0^!bK6VYO!XXp#T99T2zm3W*XruMN*M{H5WinMw1{_m)xnXj z$rt{@rp_7cV(GSb7o#~*d0?nJSwgkSU0F~+!}L=;AkJvIbI-i2)w7B5lO{?;o8}I> znDsSYM=TuekMGK$UQI0-c`rU|5&WB39wiGGvGR^KMBZkTPtah?!Ak$=ceSezeTCHL ziIi+hZqZ=IMGZrL(Tf=Au;MMGUkphEuCxXMpGSUUE(2=J6xVE1s?k?3Oq4slwPF4d z3wlEb`zO$+kP%$^ppuL!Jnp1!pR#!{G{9hC*Mgz^TxzbcR8*& z0n%6r-dcE9@RsozSw(E2IJ!Bvddr=_y{iB_Td13GSqg>6GkN@2VrVFt5k{!(Sb+Dv zaFV0kI|XsnUj}O`2{FFBxHt#?wUTxHIVi+2j)m#kRZAAAeC%FWU2NMhsut_y;RJvCW%M z0l(`0+USupdEZHi_DJV=Ymd^ii9ooVRK(QcoL=Z@%|PqWC2BtDh?}~pv#0H)NR*)7 zB>faGP9kMjg;r_OfwzQc5;o=^kY|I44DFp<_zVKcg|wRxBr?}&GIp#l0KL~vUcr8jGxDu6C{Ij@C(u=))+smF zV603Oj5A5uZJ{Gr{wgX%fSKy4Q#1?{ad3Toz#&15M8PHx_W-!i!Xbg5o%Pt`e%@!H zAI9WSPGGEcZKB{pEhO$3uJKeIl&`8ECdSkI747PT^_D-&lN3dXXzX%PeZ3-Sm$oyi z2SgmhL+e))K?ra+7^oW2t2KhBT1KFHNu#1Q+a6fr$D;U>Vb7S-;2E$RayU$wBJa(~ zh}YoGjwo>)+bLhcQBf)#DsFoe;5EAT0uz->{KmQ;O;w8t)_$qunIY~VrY3w+@dt5u z+owd5nX=Svlv;NJ?C6nh%}(LH2+t3?*q}`YlK}?F0|iF2a71}9EJF6_8ofyyHeXOy zU|dmmlInp+P^*Rdn$SV|3t|W{q?T}!B7=%Hxsotcr$~lBBcVSIm;H4;bkxo~-k(Tn znbJ_jZkAkQ;fUWbXvt^w3SB`2%klIlJU(8Un`vP5{0yZZl6fPeTB&M<;t<>_K1iu@ z#Ji-n`FqaNxe&?6eqNuj5>%)5VS0AziAh#=a)rTsAboCQOGe;Uvq#yI^<>04>CnoL zVPS?O{;h0DoX@xKZDbx31-fizgg;TS(nYT>BA;s)$qiWTBy`MiX4x|9UQ~w}*e5|* zyWaXEqGiuh5%_cQMlFHppUljn+ig;@A<4hFyxg!FxorWwsrhH7abd5W0<=DPw3& zy7(-iT$RiT!$G~{TUp|M#V^i--b$%p170*oQR_?|ua`y1I<}&;WNnR*h<>gt+yCJo z!O~(@Q3ex>q0jSTk!A4X(*j89d!dDZ!2q8!Yy@VG25T2d8Za3LL&S%t!kcRRjc9ny zt9cSjsM7${y=CRr^}|H;tdivV$6MJ#gk04r9WLHROM0i+uW+2TXJ7J13HjQ(`$Q4u zFoj~Nb1kTT{ivB@TU0k-t3Y6@_>~B=tgg+^i4avkKrCw4Vb>sC_q5jP;y7 zr&V!D|Eh0qz*iUwceuX=Xu})+YMfXqrh#Q(Nh02f3|)bRH%3#Vh^PQoRkxrbVq_0l ziT8ko6uxO}_|`y0N|*Ul`hgTK^WdR{X?}aurH@Hs;rzNpipun6temz~Sn`{Ye57=A^XsX;jkj%wwJ*E+Tm!)N} zk*99@gx;rr7`9TVQ1Wykvy?kKmXj&-UB+QzmG+m8eqka@i$PzkK&uJ}yz}*gaMZ7y0(RAW2@BE%`t1ZDwdsCHo0dVM9 z%~2OjSt62$*fwa5(9t>w0k4Tzv)k6+z&HxQR#=f`pcG)-XAbQ^fCw&>(bTw*xH|n*zj1ckL?RKJOT6uqy9ia&dGK}P8|zqO7(7+lCdGQJWRA{02s)-+|M;zOYcZ`FE?SJx7mUb}47sovw@h2aV64tX37 zraD;{E%;^5mq@z)=$OYM=$i_%>q0vuje`DyWvFI#U>6pa68=0$Y!qqhUeqW#9b}lR zv;fjDx*^4obd@Kutsr_risu_D19`J0-PHoWp*by7e@pVsrzrGENhxmZGU;Pg@qZmsfbJs=W^ z)Eb}pzK&dWAaoyVm_XsonZ-Q-LfqVa4X$`QX?5c92fTg1DPc|(AF<_(-jx+VTaC8Uec@aTeVpLGJ~Z@!~vkF z3sZxhGgH;xTW^D}=X~iK`1Zmq0Ua0@ZGdfa_RzDrwyfBT(FJqDUi=rl*)M5SHb8_* zZXK-#D+3@Et~=^_*_oh&!X=W0rG3S#y243%A@&1$YXs)_`b?r*0)MaB1`4u-wn#cZY;fBJcG0>-3; zzq(kYhMpT^-A&^=Gl3Vo?6RHb+Usn8y)=4VBdN7-R-z+J=6vpA>}8e76{iEGx~EPT z6(pJZt~})SiS@L4^J8^XD0{^9Xs&|f#k|?fLpQ(dn`C!&c`DLKhFk7A7_^0oU&5=PfCGH=d zCMEb8ceCMY=ryHYg}B-il;X~fo+&acX$rY$_6i&NZ-)vCg~+48CTzhe;oUP zyri*&QKU`jvKUfixa-_2!9REwQt!;#1rlNShIAb`nahke?$-4LB7pxGzrm45GU(BQ z_eI*F=w6(1d_cGPD|dpk?zbY_13Gl{Ig^s`B2C9n0*pSQ``j$X@D{`2p|GyRzG9jC zK0ZWwdIeA;%2|>wNRF0PwXEp&6rGy9;mvQ<*mg>3r0F<=`d&nuRGYntH43=!d_ZwE zovFb5DMqkQWtfpQ=mOt-H->4gC?Vl7JyNs|iY}qmQKG3Y6(WG5_-5i)BCZl`Vpwq* z|1DbQ`w3F7eRG~`@kz(8J~#VW-Lb~p`t%9}Dw^}tII~^urpySd>M3gpD2<)7!Yl3e z@ojz>LU?j(?|SCA`JG%slyVdbBJA>lM+*!_C`A#|(&JS_=ptHH|d{h{hy0XDEhxD_J8`bCez^|2m!z# z(7$c|v~y`o_Jt#OVQ24lNOTY67=S3G+=uq%fZ*PMakG!71W~&Ds|y(5-@xjUP{`j3x+#kXdqv9(3jR`h82u3zu9C zM*=fhe2}Qa>3kuarQ`M)WMbJ~N78e9f$q>C59;!FYZ+=SP0-|jxGFVEWHoHx8FNPgPA zW1&V@`2nvM9cGq^=tsMo(fPmD$+})h;CLqq(>Si>aIQustc5<$fF9J2KzMP!IcbqW znA=8hKr!W?I$vpTj>V~B<~*N$mf z@qu{w+_|nbT9#4h%P|a`$qnMo82Q@;UlwEOlu(wxMLf25EM25axpQK=Np{o14ZY*& z)?Hxk?m60e%X90xxoK0c+|^D0TyCt+cevKGsnEtFCNpS%+zfId)Ku7x*O=}h!+~oKs~+ZP#*G=Ov}mxf-g*pvqLL-w z1z1#Bu^N88yOf3JqffZS`9|d}aWii+F9w#SbesF|JZcpN+??3CIeE@*SM-*HPdJ$t zX#>d5tTj|Iw1BGAZWcWf-v~>`T>YS_?n&_#bmT*T?8t5Z?<>)&}{P&e6l0Eb%UuCE0#Q0$x}i`l*)8bZBj zSq*VXihj^fs98&CkYv?N{agygV!`eR%w+-o;`rB@0R#hV`n8fBTkJg=!n&Sk67OEc zRJcs_D;IM)XcR=1NjomQHy-yYK@zSvEv%88Q8{Oe(LK4hop!q3QxyEYe2KO z=I+-b1_T?FWwVIQsT9F#O8#sSn!)GSE`hHZqYfZHht@dXvcPJWAp1+-8;e{rrVX&i zi|aN%NTCpivyemR=?>cccKDdswQgK@W)9mvm?VnXYSHfJ5VnBGkf?)sSGtQP^ig)} z)4;Ky?%L-LB~sVr4Y8z@xT}MtO`|sZ7&;xctJj#OGW`fT9x`c%0xz(~;NV?56 zm^7exEN$H3JCxkPn_MX)v=3++G*KIiFy%8G(>r$**$gN>t^H1t@SHTZsy~?B4rH&| z-e763hk2V~jd}L}DnN7{n<53bPM8>Zul)?boJBxIworaIiNyU=_K=!ob=96!KznHG zzR*(ZLXFV8c^VUD#+l&oZc4G#{rCW0sZtf>u^a{kVoz=`As%oKpNvq}Hpma2mq(R( zt=TEX+?Whj%FL%!n3aOGiEA5lriF-gta(DaD-MWfgj|VNb)-dgJ|LZc8*d==5`6!0 zapyv9L@_7o7!Eg+@5r#M2^n^}XL)*WoWM1=Z_Qb8Kw8b=E{6Yv*|b>cGvbq_KZg=1 zsUvvK4u@MH?5Lz6V zCrO7xer@-7x$gT&viX6H6gEtwLPRBqp6hbsp3c^OlOsr%L0rF~*J1>APYjV`!{VR| zs&sI;j+?jM9Z^T%BjgnKYbl`&MX97!ESZoa{kr?+>;W8}QbBA^TfoY#V`^2QfofC= z2h9;bEEOxZLuQ&2A46$X#w*-&xvdEYzhdrG>A|SYAs&|NQO}M|1G=nrI4~77RdtzB z%8B1^L&+>DrW+_ylw|f@AAEj>b@;@&7y=;_sodyj9@-uiZ~j>*$27@D#sckOqqkB_ z_9)Pdk+$Be_=`M{MBX0nnhC4!jpF8ob$oKenmtJS@KF@?GQXJ%S%*3X3{lZbVgu1Q zJ+#rUW;&9Y;I+E=%HqUuFld5&$Sd0wU^6P(waMdY>5ZMd$Q(i+GPI5?PxS`)6@h_B zaI%Xg%#pJLVH&Y&0C;n0PMlGAmsl&_;b@n`47;IkIm=uWcn5Ei?!ao4WG*9?4+g}6 zodyKczG$!&IwzU;?+?*ptqni!y>r6u!-*s6b3)c$3n5mG+O7PExVZ(IZ~DDNVOa{9 zNj7>pea=suqq3S-+R@UV874nq?YgP8YpAooyStwRXQJhx1eje<11(lKBy3y*G(Hj9 zSee!jW%F3d|72Y&!rtjleCM!o0zti>G`O1tX|hzKgF;@R{s{~aUpfToeuIQR7XrWr z0P&6hS|a}!2xvZz0CoWXvi=}dQ9xFbKXg82IUp1%;QvGTgWN{}KS2LBfv`ja8K7P$ zA^OokFihkQ`sKWyOP@DdwL;@L(1)>2lAd9g;Nnjy_Ebey-N;LqmPYo$#IUYy^`5Xr% z1>!*_;(!XkLA&aspJXTi0P7!N4#EQfq<8=T z{y&0?1pLo9kfcN)8Owj1|C7W&-?RT-&Qi!mB9MUaUrw-ae^Mp+eeNOv049;Z-%tMs D9WP{T delta 10814 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 BS&;w$ diff --git a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml index 0057758320..7329a9e723 100644 --- a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml +++ b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml @@ -1,5 +1,5 @@ - @@ -10,22 +10,22 @@ - + - + - - + + - + - - + + - + @@ -63,7 +63,7 @@ 550 400 --> - + ./icons/iconNormal.png @@ -71,9 +71,9 @@ ./icons/iconDisabled.png ./icons/iconDarkNormal.png ./icons/iconDarkRollover.png - + - \ No newline at end of file + diff --git a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx index bc443930df..c00844e637 100644 --- a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx +++ b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx @@ -215,6 +215,8 @@ function _getItem(item, comps, folders, footages){ * Refactor */ var item_type = ''; + var path = ''; + var containing_comps = []; if (item instanceof FolderItem){ item_type = 'folder'; if (!folders){ @@ -222,10 +224,18 @@ function _getItem(item, comps, folders, footages){ } } if (item instanceof FootageItem){ - item_type = 'footage'; if (!footages){ return "{}"; } + item_type = 'footage'; + if (item.file){ + path = item.file.fsName; + } + if (item.usedIn){ + for (j = 0; j < item.usedIn.length; ++j){ + containing_comps.push(item.usedIn[j].id); + } + } } if (item instanceof CompItem){ item_type = 'comp'; @@ -236,7 +246,9 @@ function _getItem(item, comps, folders, footages){ var item = {"name": item.name, "id": item.id, - "type": item_type}; + "type": item_type, + "path": path, + "containing_comps": containing_comps}; return JSON.stringify(item); } diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index f5b96fa63a..18f530e272 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -37,6 +37,9 @@ class AEItem(object): height = attr.ib(default=None) is_placeholder = attr.ib(default=False) uuid = attr.ib(default=False) + path = attr.ib(default=False) # path to FootageItem to validate + # list of composition Footage is in + containing_comps = attr.ib(factory=list) class AfterEffectsServerStub(): @@ -704,7 +707,10 @@ class AfterEffectsServerStub(): d.get("instance_id"), d.get("width"), d.get("height"), - d.get("is_placeholder")) + d.get("is_placeholder"), + d.get("uuid"), + d.get("path"), + d.get("containing_comps"),) ret.append(item) return ret diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml new file mode 100644 index 0000000000..01c8966015 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml @@ -0,0 +1,14 @@ + + + +Footage item missing + +## Footage item missing + + FootageItem `{name}` contains missing `{path}`. Render will not produce any frames and AE will stop react to any integration +### How to repair? + +Remove `{name}` or provide missing file. + + + diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py new file mode 100644 index 0000000000..40a08a2c3f --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Validate presence of footage items in composition +Requires: +""" +import os + +import pyblish.api + +from openpype.pipeline import ( + PublishXmlValidationError +) +from openpype.hosts.aftereffects.api import get_stub + + +class ValidateFootageItems(pyblish.api.InstancePlugin): + """ + Validates if FootageItems contained in composition exist. + + AE fails silently and doesn't render anything if footage item file is + missing. This will result in nonresponsiveness of AE UI as it expects + reaction from user, but it will not provide dialog. + This validator tries to check existence of the files. + It will not protect from missing frame in multiframes though + (as AE api doesn't provide this information and it cannot be told how many + frames should be there easily). Missing frame is replaced by placeholder. + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Footage Items" + families = ["render.farm", "render.local", "render"] + hosts = ["aftereffects"] + optional = True + + def process(self, instance): + """Plugin entry point.""" + + comp_id = instance.data["comp_id"] + for footage_item in get_stub().get_items(comps=False, folders=False, + footages=True): + self.log.info(footage_item) + if comp_id not in footage_item.containing_comps: + continue + + path = footage_item.path + if path and not os.path.exists(path): + msg = f"File {path} not found." + formatting = {"name": footage_item.name, "path": path} + raise PublishXmlValidationError(self, msg, + formatting_data=formatting) diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md index 974428fe06..72fdb32faf 100644 --- a/website/docs/admin_hosts_aftereffects.md +++ b/website/docs/admin_hosts_aftereffects.md @@ -18,6 +18,10 @@ Location: Settings > Project > AfterEffects ## Publish plugins +### Collect Review + +Enable/disable creation of auto instance of review. + ### Validate Scene Settings #### Skip Resolution Check for Tasks @@ -28,6 +32,10 @@ Set regex pattern(s) to look for in a Task name to skip resolution check against Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. +### ValidateContainers + +By default this validator will look loaded items with lower version than latest. This validator is context wide so it must be disabled in Context button. + ### AfterEffects Submit to Deadline * `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. From 651177dedbfe75c03022c2b1f7930fdda5b93315 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:17:22 +0200 Subject: [PATCH 374/429] TVPaint: Fix tool callbacks (#5608) * don't wait for tools to show * use 'deque' instead of 'Queue' --- .../hosts/tvpaint/api/communication_server.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 6f76c25e0c..d67ef8f798 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -11,7 +11,7 @@ import filecmp import tempfile import threading import shutil -from queue import Queue + from contextlib import closing from aiohttp import web @@ -319,19 +319,19 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def workfiles_tool(self): log.info("Triggering Workfile tool") item = MainThreadItem(self.tools_helper.show_workfiles) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def loader_tool(self): log.info("Triggering Loader tool") item = MainThreadItem(self.tools_helper.show_loader) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def publish_tool(self): log.info("Triggering Publish tool") item = MainThreadItem(self.tools_helper.show_publisher_tool) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def scene_inventory_tool(self): @@ -350,13 +350,13 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def library_loader_tool(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_library_loader) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def experimental_tools(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def _async_execute_in_main_thread(self, item, **kwargs): @@ -867,7 +867,7 @@ class QtCommunicator(BaseCommunicator): def __init__(self, qt_app): super().__init__() - self.callback_queue = Queue() + self.callback_queue = collections.deque() self.qt_app = qt_app def _create_routes(self): @@ -880,14 +880,14 @@ class QtCommunicator(BaseCommunicator): def execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" - self.callback_queue.put(main_thread_item) + self.callback_queue.append(main_thread_item) if wait: return main_thread_item.wait() return async def async_execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" - self.callback_queue.put(main_thread_item) + self.callback_queue.append(main_thread_item) if wait: return await main_thread_item.async_wait() @@ -904,9 +904,9 @@ class QtCommunicator(BaseCommunicator): self._exit() return None - if self.callback_queue.empty(): - return None - return self.callback_queue.get() + if self.callback_queue: + return self.callback_queue.popleft() + return None def _on_client_connect(self): super()._on_client_connect() From 2142d596038aa37d382f5dd5c5df947ec437f58b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 12 Sep 2023 14:37:04 +0000 Subject: [PATCH 375/429] [Automated] Release --- CHANGELOG.md | 237 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f9ff57ea..0d7620869b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,243 @@ # Changelog +## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.5...3.16.6) + +### **🆕 New features** + + +
+Workfiles tool: Refactor workfiles tool (for AYON) #5550 + +Refactored workfiles tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. + + +___ + +
+ + +
+AfterEffects: added validator for missing files in FootageItems #5590 + +Published composition in AE could contain multiple FootageItems as a layers. If FootageItem contains imported file and it doesn't exist, render triggered by Publish process will silently fail and no output is generated. This could cause failure later in the process with unclear reason. (In `ExtractReview`).This PR adds validation to protect from this. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: Yeti Cache Include viewport preview settings from source #5561 + +When publishing and loading yeti caches persist the display output and preview colors + settings to ensure consistency in the view + + +___ + +
+ + +
+Houdini: validate colorspace in review rop #5322 + +Adding a validator that checks if 'OCIO Colorspace' parameter on review rop was set to a valid value.It is a step towards managing colorspace in review ropvalid values are the ones in the dropdown menuthis validator also provides some helper actions This PR is related to #4836 and #4833 + + +___ + +
+ + +
+Colorspace: adding abstraction of publishing related functions #5497 + +The functionality of Colorspace has been abstracted for greater usability. + + +___ + +
+ + +
+Nuke: removing redundant workfile colorspace attributes #5580 + +Nuke root workfile colorspace data type knobs are long time configured automatically via config roles or the default values are also working well. Therefore there is no need for pipeline managed knobs. + + +___ + +
+ + +
+Ftrack: Less verbose logs for Ftrack integration in artist facing logs #5596 + +- Reduce artist-facing logs for component integration for Ftrack +- Avoid "Comment is not set" log in artist facing report for Kitsu and Ftrack +- Remove info log about `ffprobe` inspecting a file (changed to debug log) +- interesting to see however that it ffprobes the same jpeg twice - but maybe once for thumbnail? + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Fix rig validators for new out_SET and controls_SET names #5595 + +Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed by the subset name. + + +___ + +
+ + +
+TrayPublisher: set default frame values to sequential data #5530 + +We are inheriting default frame handles and fps data either from project or setting them to 0. This is just for case a production will decide not to injest the sequential representations with asset based metadata. + + +___ + +
+ + +
+Publisher: Screenshot opacity value fix #5576 + +Fix opacity value. + + +___ + +
+ + +
+AfterEffects: fix imports of image sequences #5581 + +#4602 broke imports of image sequences. + + +___ + +
+ + +
+AYON: Fix representation context conversion #5591 + +Do not fix `"folder"` key in representation context until it is needed. + + +___ + +
+ + +
+ayon-nuke: default factory to lists #5594 + +Default factory were missing in settings schemas for complicated objects like lists and it was causing settings to be failing saving. + + +___ + +
+ + +
+Maya: Fix look assigner showing no asset if 'not found' representations are present #5597 + +Fix Maya Look assigner failing to show any content if it finds an invalid container for which it can't find the asset in the current project. (This can happen when e.g. loading something from a library project).There was logic already to avoid this but there was a bug where it used variable `_id` which did not exist and likely had to be `asset_id`.I've fixed that and improved the logged message a bit, e.g.: +``` +// Warning: openpype.hosts.maya.tools.mayalookassigner.commands : Id found on 22 nodes for which no asset is found database, skipping '641d78ec85c3c5b102e836b0' +``` +Example not found representation in Loader:The issue isn't necessarily related to NOT FOUND representations but in essence boils down to finding nodes with asset ids that do not exist in the current project which could very well just be local meshes in your scene.**Note:**I've excluded logging the nodes themselves because that tends to be a very long list of nodes. Only downside to removing that is that it's unclear which nodes are related to that `id`. If there are any ideas on how to still provide a concise informational message about that that'd be great so I could add it. Things I had considered: +- Report the containers, issue here is that it's about asset ids on nodes which don't HAVE to be in containers - it could be local geometry +- Report the namespaces, issue here is that it could be nodes without namespaces (plus potentially not about ALL nodes in a namespace) +- Report the short names of the nodes; it's shorter and readable but still likely a lot of nodes.@tokejepsen @LiborBatek any other ideas? + + +___ + +
+ + +
+Photoshop: fixed blank Flatten image #5600 + +Flatten image is simplified publishing approach where all visible layers are "flatten" and published together. This image could be used as a reference etc.This is implemented by auto creator which wasn't updated after first publish. This would result in missing newly created layers after `auto_image` instance was created. + + +___ + +
+ + +
+Blender: Remove Hardcoded Subset Name for Reviews #5603 + +Fixes hardcoded subset name for Reviews in Blender. + + +___ + +
+ + +
+TVPaint: Fix tool callbacks #5608 + +Do not wait for callback to finish. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Chore: Remove unused variables and cleanup #5588 + +Removing some unused variables. In some cases the unused variables _seemed like they should've been used - maybe?_ so please **double check the code whether it doesn't hint to an already existing bug**.Also tweaked some other small bugs in code + tweaked logging levels. + + +___ + +
+ +### **Merged pull requests** + + +
+Chore: Loader log deprecation warning for 'fname' attribute #5587 + +Since https://github.com/ynput/OpenPype/pull/4602 the `fname` attribute on the `LoaderPlugin` should've been deprecated and set for removal over time. However, no deprecation warning was logged whatsoever and thus one usage appears to have sneaked in (fixed with this PR) and a new one tried to sneak in with a recent PR + + +___ + +
+ + + + ## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5) diff --git a/openpype/version.py b/openpype/version.py index b6c56296bc..9d3938dc04 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.6-nightly.1" +__version__ = "3.16.6" diff --git a/pyproject.toml b/pyproject.toml index 68fbf19c91..f859e1aff4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.5" # OpenPype +version = "3.16.6" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 9143703a73931b6a4b0c63d0aa939870186c8c43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 Sep 2023 14:38:07 +0000 Subject: [PATCH 376/429] 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 7a39103859..eb8053f2b3 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.6 - 3.16.6-nightly.1 - 3.16.5 - 3.16.5-nightly.5 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.5 - 3.14.9-nightly.4 - 3.14.9-nightly.3 - - 3.14.9-nightly.2 validations: required: true - type: dropdown From df96f085f2e21d4a3c8a2cb5e3d33001eb40b99d Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 13 Sep 2023 03:24:29 +0000 Subject: [PATCH 377/429] [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 9d3938dc04..c4a87e7843 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.6" +__version__ = "3.16.7-nightly.1" From 5de7dc96dda39f51e0664bbd3e572639cf3d3130 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 Sep 2023 03:25:10 +0000 Subject: [PATCH 378/429] 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 eb8053f2b3..35564c2bf0 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.7-nightly.1 - 3.16.6 - 3.16.6-nightly.1 - 3.16.5 @@ -134,7 +135,6 @@ body: - 3.14.9 - 3.14.9-nightly.5 - 3.14.9-nightly.4 - - 3.14.9-nightly.3 validations: required: true - type: dropdown From 9369d4d931f8bd9f7e02e480099d64a4bb89f9b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Sep 2023 09:46:48 +0200 Subject: [PATCH 379/429] plugin does recreate menu items when reopened (#5610) --- .../tvpaint_plugin/plugin_code/library.cpp | 106 +++++++++--------- .../windows_x64/plugin/OpenPypePlugin.dll | Bin 5811200 -> 5811200 bytes .../windows_x86/plugin/OpenPypePlugin.dll | Bin 5571072 -> 5571072 bytes 3 files changed, 56 insertions(+), 50 deletions(-) diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp index 88106bc770..ec45a45123 100644 --- a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp @@ -573,56 +573,6 @@ void FAR PASCAL PI_Close( PIFilter* iFilter ) } -/**************************************************************************************/ -// we have something to do ! - -int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) -{ - if( !iArg ) - { - - // If the requester is not open, we open it. - if( Data.mReq == 0) - { - // Create empty requester because menu items are defined with - // `define_menu` callback - DWORD req = TVOpenFilterReqEx( - iFilter, - 185, - 20, - NULL, - NULL, - PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, - FILTERREQ_NO_TBAR - ); - if( req == 0 ) - { - TVWarning( iFilter, TXT_REQUESTER_ERROR ); - return 0; - } - - - Data.mReq = req; - // This is a very simple requester, so we create it's content right here instead - // of waiting for the PICBREQ_OPEN message... - // Not recommended for more complex requesters. (see the other examples) - - // Sets the title of the requester. - TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); - // Request to listen to ticks - TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); - } - else - { - // If it is already open, we just put it on front of all other requesters. - TVReqToFront( iFilter, Data.mReq ); - } - } - - return 1; -} - - int newMenuItemsProcess(PIFilter* iFilter) { // Menu items defined with `define_menu` should be propagated. @@ -702,6 +652,62 @@ int newMenuItemsProcess(PIFilter* iFilter) { return 1; } + +/**************************************************************************************/ +// we have something to do ! + +int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) +{ + if( !iArg ) + { + + // If the requester is not open, we open it. + if( Data.mReq == 0) + { + // Create empty requester because menu items are defined with + // `define_menu` callback + DWORD req = TVOpenFilterReqEx( + iFilter, + 185, + 20, + NULL, + NULL, + PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, + FILTERREQ_NO_TBAR + ); + if( req == 0 ) + { + TVWarning( iFilter, TXT_REQUESTER_ERROR ); + return 0; + } + + Data.mReq = req; + + // This is a very simple requester, so we create it's content right here instead + // of waiting for the PICBREQ_OPEN message... + // Not recommended for more complex requesters. (see the other examples) + + // Sets the title of the requester. + TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); + // Request to listen to ticks + TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); + + if ( Data.firstParams == true ) { + Data.firstParams = false; + } else { + newMenuItemsProcess(iFilter); + } + } + else + { + // If it is already open, we just put it on front of all other requesters. + TVReqToFront( iFilter, Data.mReq ); + } + } + + return 1; +} + /**************************************************************************************/ // something happened that needs our attention. // Global variable where current button up data are stored diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll index 7081778beee266e9b08d20ba7e48710dd7c4f20c..9c6e969e24601f3686477dfe6d7127561f2a3d9a 100644 GIT binary patch delta 102578 zcmZrY30%#~_sx9Q_C;yarXoU7L?xx-M##R)o?R$Ws1TBf`#5CZ_vO8d!Sig9ecyTZ z?e;v|v#8G_ImB>qNH4;GO zR@yFPC%L4vrT>sj=e6c0B~BLRCa3V9Cg0MKSO1g$VE!{)|DXJ||KyMTC%@}I`E@_? zc7_HuN2Q9dJDTu+CHdN4#Av&^*sWZ*M*nm)(M&oV*tMjAxwb@XY5QmmFTVNed+~Lx zMrvg{*HD`Hqjaoxoz%)M)=;|jN2#-!)>Qt3hDzFFazoputIbU`dEI3CGVKNDkh6#eEf(eFUBW9lN;j`xF5S}l6O^;AMevif8@ar)Gylc zRh%q#8w*sVy{27WrFLd07EJmeTE~9KDa9(!>gQrX);OP@CaK>23p9D{aN_ggeG>G8 z%c~ms9VO)shYir=98WRdV}tGLy&svGAk{eRjA704PU=0&2^(NigGF!e^Y2-`?E_uw zsw)MOpnY52gLKk4_&Y1)f;OPeaMD}1w9a@LA#M$>lhNAu4epS6+N+_D$TsaCVIRp( z9SeVHLyqXywFn?&uI^gPQo|SsNIS4yI=QNQ*e*-4e7E_> zjF@iGj_BrMwXwY9{$?|{*GsgwSZ*xi({w<$yPLO2?bd5=_v}PYXhVA4A%E!{dkcg- z*1Gm-Y<_SJAIvqqZjBklpH~2hWw_> z8K$SUR}1+Niuht`G(o5IFLV<|RHwF!=Km-jz>Bx*c8}Ul$QIqO)boV=sf|n%oulV;Mnohf30ro*e8TGUah^8;iTP` zQImYu-OflNM5}Ewev?C+nY@?Lk(#_2J_nGMc!T}5%7g}FoHlAgAdOq09W`MsPM~XM zT{1-5Ix~`K@)Fw{~qIDN*S5HbKd$n$pUy_qL ztDLa}chIyc&qyEL=&7EH?UwO;d^3DrmNi$bTRXEpvF$g`SbVpvc)afAtb>I7uKR6v z6C$W%aWhBKK&`*FyD(-9W}>KzcC|KIaKcPvBn{BI{IZ!gNYfqv#hM6yqsuVf&fQCX z*ZwxI6LPRQbfHR?HVHbCFWUVukj7+ctIzL98%)zqnm>Y!(Y~GEi44`XTyUS7hG&?W z#OE*av4K~0)C#)zMJa@A(j8npMHY4^nZe>y#7WnCRVopx^)Ji)v$`U+4e$H&*xG5G z*X<^Sx{K>VDM{2?ZM;J=wa+(>BlC2FH?<j#F8II>LMG|{Iv7grJT*U4)o-hfBD&Ak@3LuK6ibN}b}g->!J+($2J` zWUa3FTq8=7bn?Y8O8pxnq?T^P?4;AWYORs+>BXyr| zbRxp$05h0*PO4<%a@)-0lmW=?;*oAb|&3yj?N1~nZF@!d^ z)3T3ssH2_s>Bm;2SQqwbI3QZ{b@-@|8+&;Hx7EAeUH8 zC*nrPCur_WZjozH&4pAUze0iwNvAF{oN^(J$aQAnO6n7`7h)?RbS@iGi8Li-Hrwt- z!Ubyc6#ntVy6&*bUZf2n=U8fGvW(CGJ#+LS*$Rz(3x)pVC`p6fHONV_k=3e6+7Z%$ zO{zt533&sSfmr?T&?JxqlPQo9NIKAfo9tpBSxHDYNUK8{x5>M1%5@S=-ibYaxMNB( z%Jb)VesFodC(m~+&nNJF^YVNa&xc)S=0T)@+MmB?ircfB&lp^#o{2^J(HBikz`7oB zr$JZXY+cfWT!dQn$Yk2`GHkDh{Vjrs5Ym_QfR!O+ItSLr+%VAACxfVP5lZV5EzdR} zv&e1c9ZISavI?4q;XL+*yfAVcN!o{#;iL~c5>6&i@{q+eBAJx*Wrw5q7xOV?`6dJ? zImNogk!IFp39Rizwvvg^u`}sLeUGvook=DUZGOf6n)WAttY=r^Es%pSxd-`;t~dyt zI+Lz6_8^;^$ngR>Qw z1bU>B?PkyCmLbhDAbFhN`MeKLX;5j4yyE!W(Kd0YNagl$;<`%S2B?}!>e1bEAu*MB z!?w|671;#s(}*{j24mAmY!%P-oW7mU7^IHNJEgb5d^}gon&sPv#R-?2=UW8pKf}W` zQloD0FQz6>>y$dAH0H7>?~48pT;_ihZ^nG{eEWcSJeP$l2GT-^9YfBTKAU6cc1k+* zN+XWsGz5<&z9??Gj3xCdY)9Zz=Te6^k!AHZTnj75lJjOWW*exiC(kg>$N*T7PHNdW zt}!)fk#{&Q?{XXt(sT`6O(%g>8zS=4%B2qD8!9;^ z5@6k7C2!_PRNf`tn|1)>No}ujh|4>(H@vJf(oa~zN+=mmPMXE%msPpSP$l)thZ_?} z{mQ411^L7#m#xZP%<~1ob_}z+X}O`Rbso*YW@?o>_=c4=GweTvJun}aZy$n$oM~Gv zXR|YjMx?fj;pQY1Cig*@Ov1<})@U-W2g!ndIivwu150zr1@9zeUfeET)*f8QnP|f9 zq_}8Gi{kT&u`W&CWqp%E_RADvLs6`*o<;-lM-4Z3$ElL^c2*cfzTl^7eCY@6W-0h?XwM9=aWU$cO1OPCnJTlIi_%> zjT{O~W)cU%caEv{+!z-)HpKg`I*|s>U>knHbAifv?CxCBfYOuG027`pY(6oUQMNW+g5cpWZV4V6P7~RsC8Rs? zK0BDN$2CpvWFI~i{2Y$e5V#WM{A@_(pVqK# zB{@V+z^GLupjTm%;Q-@yHU5jsiwx9+9ZOY-><&_mTD4=Jc93Br6}z)R zd&pBl9lNrQd&zT3t9D{d_mguXY0sPvlZh(n&laA-!;oBI{%6QpO64&2Q*fM9ziXw8`wU;;=XM0175rmPCO)s$!(bRi0q|awV}mh zl0@Tu+0Mr}_0+g?C za}6ah`4veKR2Nh2&`qJN?mwgg5q3Elb2+bxsmcfOnG}$F%<~KJBh<%b?n3IzhJQu1 zfXswD-^d(c7WyF09&HJSO377naYrGwkZhO8#+JUD5<3}_XcV!Hmwqnq%S;tIme?K= zjOE`o>Kja}($>VbC;hp640~%zClc}&#+cIr@`0IJP&d@p&6uAh#g*FuyR5K$7mKsz z<+8#C*w9ETdiVo$twKH733oaSy$J7Fs3%1YM#uJ4rl{8zu?;?SE1?x$F+_Wgs`d-k z+>dTSJ)C{2h7gY7PtOqgrUViO(~7L1CiRm9&2{yMCu6-Z?V74RYvlrSgJ`tiSC)Aj zM6c4|tFS+cx%P6pqHwQKT5cVrdEKg*p%jy+-3P~=@>$j4#CW}I1QEC)5)Zmt!+cbRz9RsI`_QPNq`{oif{4^S2yMYl)(7DqTgAm^_VU5&AgaSo4xx-tN$Od2}^7 z%GTs@Sd-~SSVTUDorYyIu}@W}v8J==7ecE|VX?ERD+-$&)_V?(F%`Nbe;@in+Ru#K zU{%-93o5GkpSIv2+OqVmSdZY_m9ww|d%cZ5l*xH^jZrk`_Q9)Nv_EOhI_<`+MMQrb zTHew4{BAzUuslMwhk`w{DaYPZhW)z{d#?ez&;MZmVZ{EU411pud%pp@=l@_IpnZ_D z-tZ63eWydTl2d380~U(Qs@(oAX{Q=ajswu@33Y^(hiF}L4z3@fi6j{s6wyR#(;W(n zaOStcN6eB0=zJJIFWK(HvVtt(wH;CaQ6HN4ePD zg@+$+>7WJg;GksIy9re(C9i!I)E+L5vd)# zx=!m*uR7p+17~qB-=w#wbqqW5C#^(m`qaX4^D(!0$;UemUfrVS z$p%(*8zlu<#RBfqdFXh6;`_8cSqhaNpa^;oqaR@I1)O?7(Uc2k4| zjcAxT%qD{C-1|ZYatsQHPzl--A(V_@nBGar0oGI$0t8aP(iEW)dVU!*6PgKB`oaV& z;f>VaX zgu80033I6XefF@L;6=!1Ci@F0T<$ZE8bU=vPC~t!!hULg8{XFxc93>#b1lJ_&{}`8 zO94V-LjD5B+JZkB3DLEM&Ro~3EeM44gynUF=XmZH1PQgs7T6yo^dfh`D_D3<1Fu5G zxHsAV}!AUPC5@GVg!eIO@umVI0rQm!fEpxFrtYN zMb#Uyy$P=JN%pddphzT@eP}Km6VaqPm>?jJePJWo2wA3hWpNiQ6c+pD;hMwI7WOkO+O{he;-=#ZZi$yfS;-+I&5H?k)J>YI^n&v^Jmj{)GHD6!j5K*n0h3YPT1b^c9ZV zHvRo4Wev`3c$sTK7lbW0=|>SL{6;?k`j(m*5Hi{0e4C_?Zbc z`DZXB;KxI=8)paI5}N!L3HtmB1T8R!CF}7{f#>~q!|Wu%m0InFE%+s`*oh?JBPF(Q zc#tsNrss~I+HTEy3>JD4RO0pz!DTqE`w6-SvjvbiQV3QPTkvJDUCQbX6jFk-kck2YKcrpbb@P1d%bs7A1eWLz!V(5*ie z`)_4ABLsVbs$}6vp^DA(%|C(fz`2pgl|c)j!YCmDrE%X;!Zw@2O+O*!flrEX(B}BY zpGx<@+Z1eYIn+qS8Tqi`CtwK_r{ZdifG?@I8WW+xXrWSgKg21U@j(3s(JjQN*rN{?FyX^I2%7 zFoxL9$uk1J+e>&lX9=x|UEuVe%QbVM%0v`Srb|FK5e4LRxIa+{AnUVV*hL!jwCG5f^p`wiHQAw^amExH?Vg^d!SFnAC5K9iSPcsD7mV9Hv5}}z$ z-m;b}gsPO}!RS>&QyQHF`&S{Cm>9mU;I>-mCrs$CaLb4-T`lYUK(iV1oz?0=W62r`f$d>_$xD}s$vq8o1q?!wkXAan*8QA1{D1W ze;iHZw5{9FS%ah8g&!lJ69;JW+_|rgr?FLjWuAt~yyyh$Jul5nquX&{lo6;RunVV& zGt&5!(?t9G7>Fj_{Z>X}RZdft)5PaX!Xei6j$ln_L?jHkD=fil0rPu!Tz-R%{L=%P z-!~kdPw_+D8^VkS!ds$c6CVm=WQum+m%juTTEl}mJr$107Q>u=^f;gW{YrRG+-q+! zu14O$A|3%E9W>-yqvH&({||z#EY@NHZv;mE^>dBId|tL-66bPf7I($uozbV1 zQ)mqoqmkl_zE3&DIQSYZ_Vc`dR5ppv9c80A8tr6_D_SfdJ*wJe7tK%E#29fOrQ#82 z+*IsMjtABS-*k!U|`X({$6e}h9SqzT>&b_oc41U(aw<`yeR z5Lbw}ae~^5HiVL$5ZeL4zk#-c=!L$O9Ua7GDp@}-QS?SPyHBF{3=Ivto|qd2^?Hg~ zd&FR@n8mUTa7^&loSdC{sdRD(xp?BU)_&cymYLd0*MeleYaX2^f`if}e?PZ01#X2IbyqujGAf|KeFm8~j zp^iI2J4p0HOK0yOq?!q?gT=3;0Gx-2&1md)=r=^nA^X_VA)*Bu@Jrz1P_YBLm=cDG z6{)-)1`HGDQeiQa4ihg?+h5t`;bH|s4nRq=*o>q?@CdOlwb=@VBg6^3(vf0I8np!m zjue{-Ef&a-9j!XU*^%OCesMTTOe9@l%_!{kQ>d7NpA49iB5tc&Zv~%|MLz5JL}VW> zz)xb)ZYTWcx6YGIf{tr)k+U`Yzs=JFM#u0x7@R6Lp{?eDE>#RA44$Xr`Yi_c(c&QL zxdEn+7UPf;E{(<|zXfud*qF~q8V=fFJ)4^*x?r#YY#SqXLIuopthkh#tc49@aTK_6 z>4;T#4Wy=vgUE4qH(k6#Xw)oraGZ#akpnDhf_Rni(Pr@}T*mrkiS~px&4r9?(U*GW z!isFs+aqi`ACy7Njp1Uh<#)$+eoIfi`*ij&8~KAT-z1zfuf;5Pk~kL?Xz<7pmy^lt zV2+43|03|6D)yu93t+}naU|;CrBlT*wDx=$J55}Qs=9M74gk;Bk<-PFIGZP?i9y3L)GMUwc%&Ovq4yZ3(4D*1{;7U z^#^z%AHhwSvQ+f8U5He8bD+sHHQs>d!JegJ2)9y}ij8Q6ArQ1otcG68?#smBI33nv zxM(o`;Y8<<=}Q(G%Xz*h&*zor6L>zOJfFq$c(N`NF%|?}Ei1&z6}<+d0t@B3Fs==+Vqyg=IsNpx0R}d$~&NN&xP**{I-9Wc7Q3)XY%I{gZcuTX*afYK5!=K|sGT(VRop@2 zy1>_8k*861{w6+fywCxcAV1FpRS$i!A+1|xVPb;)kR9O8DpQHA)!`s$wT{efhggYF z^9~TUQ@lt0+Jie2U($N*z-AZnmluoQC8E)MANuST+oD8cyTvfaHf{I{kFqhuo?!x) z&0OEG4Vdl`Z_ysDVb^Xk)Uk5w{|CvkHEZy@*ovroTOl-0*AveFF4kd(_KIVOuT?zn z^{k$#O6aLEoxs!SalCx#OTok>=pAHh1P7L~PwYj=aCT+CNVsi%?EuQVG^l?N#j|4~ zEI5ebm%#_jqQ%|m5PsgXU5CUec-PXDMII5)5UbE=-h-1EW|~QV*Vi14@$vk|Ri->seV_1hcFurrr^N`WMnJ?FF#^3cgPEb>obIJCSdIP~g4yH)L4cCoD|VI=>MY(!_pt(C#!)8#z5u8sU=q`$r#{To?ihA zpNYQJe!=4J;%^gAbD5ZAOq}IIh{PcTCYFqWl4mIVhqK7%s8aA1ej&be91-~c^`I-X zdx^M(u<<1_RV!C`|5A*jqAN6hC03`617O%IJl`h3(O05Bx^7>;LIrjooBEIV1TQLK z_#3f4=chNEpV*x@$euKyBC~!cdWzJ+4r+fAJCi+-_X*F&-tgd)$nXGN_*pzaN}$^p zaTv12$uFp4##_PTFJeQyQS+Lot?w1Hb+RMLyo(uVa?q}?)l%UrFbLK~*yTx1{S z)>0ArQf%Oel@x$?LT|04`GhvNVY6&7PhG6xfUUHgq_N(165b?Th1(UR1vYQ#Pt+}8 zs=X9IMGM$vFFm4;=4`2h_}ao59LcdP@hsgKZv?yG`7?pOD%>ke3u-+No4R?#Xv>fx(!io~3M&mo!Kr4?(FW z)yDIpK{YAPwHt2$V>Nne^238Te+|H679(Kv(eGh#HK`soe-D?dN!MuYcd)g(R9zf= zPcni0>XHu-f5{%z{fhor8-T`Ns)BmfaDQnS#UbADmn!1toxc?CvgnPmjZWX&=vmgr zBtfE0$eZQ}#5AFemxS%5UwN`8XK9Ir>$BE6QS1WLU{ zj7z&3EY-AVc3I-47*{t2f=yk?!_v3BB;qm)uPYT((?NG6!~IWB9yw5fx!0GrQnHkt z36%~K?rIO0PU0;&>lPv9nww2MZA14Xw*lN zsEHv{i@VTp#H#wIPY{xW5!lT~~vDk~EMvpCoC}BXc_mNvf^_j{#By zziAqPBx_*_{~!=QwCYOm7$`*{zxEm^b>+w8K-^&&@XtUA?Q57aNSah}^$ui?$ahCc ziq)P-+~{0bR$UGrgQYt^n$7<>OV6zOgC-srDLC(PQ3005`sfxX8 zIfijMPUPs28*!2p)W}Fo4IS4n{_bKo3W3xq;_2~XAV~?`NNl~uDXCfye z?tu7w%S26S=^ozinEb}4^zGr#aZ+s>G7GH7Bgfo@*5jqBoLR<84KP$oHy*beGRt_W z2CXs^JSRva`A(f6t)*u95S}U3KsV38OeqPYr_X0fXVB(#&V~IGC2#7U3lAqsnZn{}k}*OwbCP6*zM7enq!R=q4JYHa#_&h{P`{~g zV={IELrQY+6vSw|9H|xb&5>%uqa4YRic{cQ4#E#Z!zoBL8OGy>#^kVlQ>3;SP%#NA z6?Di&{^Fkm=GD8j=mxdD1*yoF^T{Lwwi_=?=fp$d}e|`Ie887iG^(lsyaC z?U_=X&?;z*WRiCcMZYPE{urJyioBx8wy4E>>V5wW_xWFVY1+T&#W8I39Gq_$o^&mQ!vr0-th3KDE z(msLw0>7@qew~HK>!fk0VD(uqt!~?-r{U1%-sOInERJ?U$7OR>A75VH0+U!~qxUQ? zYhWmAiYsDWdQR_)Iinqm(%~=Yx&f6*3~JsedGjmVjo62-FnyyGRI6&LgfX(karqYE z7?~M#A@1N^LGNTJ#4z5|Pn$N&4}Oaq5JQiP^`GGBMk&DhM2dtluND|gTpV;ke+a5= zLYaYa7MrB=^!Fa1Y(&8cFle)sNL@$4$<0!oT2FA{{->F1hQj}CW)JvpkpgU{Be=Yo zi6f!W)9lHR2Q^xQ5vTRcM;vM!(BH!Np6R!MCJa}i#VhP7q-9g?-!s!_tf_&T#LM8234ix8_W1z zYC-x;X@SDAOr=}J(?8f_`oAECw1Gjo{i`&Er#X9cR15UIc;-8Y{LA)oZ3X^+NR#o_ zYTF;u19F}HvQMf&9Q!phOz7{%3H=_u|2yp4k4P(F#R2JnaKD)Z{dS|RjHm8FbMP-@x=jV|w##OlPyr$E8~m9UTt&=TLuGe^#=ivElH?S)8tSFyox$ z4%N;{b|?eF&q=M&;fzIrG$su8okMA3W5lpNj~E#1cOJQ6F1+GI(|J)}SbQF3goClj z>;e`&1h)%VG#7?kK+^^hE=VnC#RkUKO)nw_#tvV^mJs8j5#yrNk~-9fXBSa0Ao(RE zm=|Is$i0LF{b120sTFkyfoGSDD3_&u)VChox-4a)>E8Vcmg0+_E7D9}`BgkqT(3%2 zF!rjnl2>>QTl<9#y(T^6p;K*c;#t-KHr~W4=uj(lETcF3?NfUM;-wAjy0AREQ0FXS|WbhYx4COHPxU;iPw}4;fF; zY76z|HE75@*1kl-H(Cp!|6fvXTHlME_)ChVw5|u(K9js%_Eh3~C{-zSXmVP@^D4Ld zwB2J+An0|_@|pCQ+PJfN&!s|&K`rNBN}bVMc40OCkz$C4{Wdg^QQaO=lgnQZlc(MW zzn2FWgZY;4VAgACIQphbU*j>7%97tmF9d4o0lhv*j>=41+*tZ)Ec1i(g`n?$+$YJG zVhqZPFVYi%JeWIOb|t;$u8|**@oYMgPZ3(to^=xBLxi+pu9AF5q)im~Vk&p%k#uIV z4<3-2X7W-D;9{QUvYAR7i>yWk`5wmAya#Isxd}#Ib#Rc^QTsRZD$0$Co<&!bTTt>R zo9!gmC)}s+ERUw*KhW4kjzvH0G#9xx!Ps3Fd9y9|zt_OZ8{Pran(`8~#n#o7cN3py zH^lEk(&DCQ^8Ttt`ly4v>K(6caOF5D<1TnLxyIf z8_HGC18x(6-7bWr2)UGh8W}&4#!r;-)0lr)QnZY&-}!7{jJ%p!L>B$%L?XB}l^5GA zKJ=qxCngKv#5}Q{{VY5xPwAr$i^#U|_{0r&Cq~t4WOsDn25MwKyEpwL6MfN9gy&oK z+|t@a|C$Zb$Rh+Y5dLf~&mdFSz<7Bqk$Ugrb?$jZYbuSn&f|{`r` z(?NSgyzo|WU#a;TqVI}Xy^4PtM60Q0yCuH8nqt=4MhLpV1F1Z%tHtaXuMblYq< zzlhiS?qOXMWSN?$o#Dl)Zuo!&Znu#u!o)UmYbu?A%WdRva+KM%l?9mw=)keF9D>Sx ztIl#D#wuoYmQ$$3Z}6_OJkxdDuOgQkn%r398?E|yM(wuY&Gd$)U1Yxy?N&qE{J>v> z$>JOhVxUeHJ57_DlYr^6>(dqm`j~Q5bZB>xT`cMuOE?>}2cNET)rtqVh$aTb!tl`D zs9QL1g0)e^T?U5Y}Br4-iZ1F8?ahm>p2Pw|vRA$$Fgdorb$@!-MM{ zVBJSPBGgzXf>#czw9os<)3__Kul$f-S$69$Z^nxQvn1J%qGKg!pgarTg%%Bz0|L9m zAe&TMV_=iw8OSD`H;5)B+tA^Up<0T5;is>daQK^t3h?{_s5}TC%5`S*2FX(hn)_}; zLhuvZr$5ZMM5ieo#z*DEFU8-Tfv`{g!r|nR=1C3n-8;8m*QP_PN ziu`&CoQKJSNG~>dm^=*K=sqzuj2^~hulTfo*Fj{`fZ(DQ5V3vl@0kd@34F#ZIy1WtnGMCcjo~U%!&5%3O z8mploLoOuSng2L>2wr2agazZ}+zN+Qh$jA@jWGs(d|NeG0CgwGUuf8T2+G7wod(l0 zasG}lmn<3MEu}n&%9iVJZ8#h2=?nL=act*V;6!Yh+ia8MHryFMNp6cm;_W24ox|s; ze9a9viqrVqU?VnK=rI|^0s4+|5FgEi9K>G*(iFshn)5&SCbMDK6vRi^#E(A`4h*>he%^1h_&yw#dwADyh4tNS;T-L=l09+(NmPIy9L>wz@;BLWI0u zDA%A~$>6(4j^;*J3$~io+mu2{PkD)_IAH(IJOn>{6NA z>${iA!>RocsJ=`dih9lbWtgoy7)+PT!#UD&WZCwhUoI!3TGn|5=2GF<3dE5HLW7l< zoeS$$V%B#6IIoh2R@#{)mR+A{!p`Fxv25cTF?{@7vJh6R!ee?MyR=Fslv=05)irWw zYx{nDCiyd4WRrkoXtY+2K^~mER`#Y%d&By*awL^{v!`q2LDcR=FOF=8k1^mYLs`-W zc`c>&5}D0r8H2NGcLj%EaWf#t{3f?g~fYhSLV4_-ix6Q z?A9Oh2||x_gxi0}mEq)mS%>!knFr){&I6nCZqD#IR>u9g9llq)ZNF?k@1VQ|EsCuN z5wk(FpD{bP|2HNJJS307#bWD=Q#tsu{;#okj^{y0fXVV|V$?ctDS2Rb~&*%Ion#5u15lR`^xSybJP0ya*U{ zQO>q)UEf$wU|BsF%y|)Kx)0pABxl>*t7n8pmO)8lHuSQLfu{YPfs{GV> zSCA3!W!Xet!`|`rF@X`cuoru;;bP|2fnGOd2RLzE9%3igF%o=_gRIh!X>M?da0`0g zlqcGD2{gjLznMd6d{cgeQtkSma;DwGT0cX@P#APee$H9{ww!6(x26&7S=Q^xtmL+g zkKp`kK!v;VC%XXupBryg4?OP4B{aA?nBA8r*lw@(bM=>C!F~A!O3mB{SbMPF&$YJ$ z=ZEqO8eA339&zF-KZB9@k&*Z@_B`0v2>m|Y?E>NJMcEq~K9Nt+ARjQ-%Uf+HdK-~m zmi2!HoYdp!Cc&N(d7JG8FC)|j6&lV!|3JoH@-ph>1#VB}d@_e^d@6epG^0=dEw`lA zJeb=vRAi~n4FX=vAVVL?SsJ9bqUdolaZR`)KJfq|g+bAi; z0zMWPX{wwRsJ|JDwp4;df-gU9l?cI%Q6q=+q>yE+RA%?=6$kz*j32vZTbYN1}b@8=_4eQsM6MINwKMkN$g~N zcJGFh9JR|3!$iXTQS3P>_~0pAU_#0$3gV8tDu#SRWu_TXJtmAVQ_j5)Bho{~vwfk%jvfl_H<2#!Px zCqk5(XqtWqQJRrD5M5vChL-ZO`bsF-!*14BFx2V;j0jZ*IUh{I4(~GOyDpEECa6#6 zV{`=SNvZ5jsB)W{SLu(=I0eD_0=U&s3AcEP+V~!fVlmOjO#$Bsr5BH-k5Ky9Ohmxb zsZqNyCImGshYa`>p@drA?aPgBgJ-p74n#FloGYH-Mfo#)uJKAz8-Fb2F{7Iyxseh_ z@d*9Bkj)8_kNd2b~fJ>cGbkRyA-qdrQ6z1KR9jOTGa5BlNwAvy@@t8}AP_u|#1 z(#9-gHIHb$rmwb|z3Qv@;uGRk#)41a(O*e3owm}LO^2oZm9_Z$6it(qd~{qahbse= zm+0?0I#BVk>xGA#K5z}9=3Abgkzk_liLX9!O{w2<<~~@#M>gmY9-^dBkHv6)h|&OW zxy*+uC#lmSc5SFq%AIi6hAWj_ZsUxjH52}ym-|=s`@udLcT*A@l8l>zR^AFHM&MMP z2B(oqZKs7>aG3cmY5%CBa$qgIZ{Nc&vGmiS*GQ#08VfTU8}5u%d|aG^QI6)i zRUVJsw4G<@vO7;l6@pv3(wep`gyeL^+j#*$+j8AP$000xt^uzePyY&=(v>#2X-m_2 zZAPs041~P;#eg-Mr@aPoz!LDYa%)lExDO>TY;DINg z{`pJ;dR{7`yK7lwwt_K7FQIgzGLeSOhDno@s&*@?BO<;M;0tCo3K5sX-bqSNZct5D z=F!@VV8>*oH!2HGIZ9iKhs)3$1+UpFW<#Z1Wr|Z{KuS~!J@qz`-!L*O9^Wt_bLn7D zt}+Ctrs{N*=Kk62>U7*ysFF6Bp^T#une6Zk1s_NrX7BTr82&2&;RV=vbPNf2_=37SabnSFjvk=cES}~ag zE>e69FZmWL3H*j_F|z0)IKNmqW|}jY_vC{K@e%<1)HqjE?1i*4Xk>xUgFBv#YG)dV38r z^r#juZ_O;$C|d|M84G*YDoJ>I;+V^E{l&Yx-$0yGZ$iEMu(>yfhe(sbJC<*0QWI%5vfqv? zbqTub^e2=y)GP|ZPb!V@;m){|%5)5=QBElnF$jJ7DW#3&fJRttmNh%893kW~*qm3E z^3(CW(vmg_op%9cDY^G)v*F^64fsaE zf{RL3w{bVH<*wm|mhVF+l=)a;4)|7qt z9QtDoGi2O&dS74Ong{+prFS-@EBLav4;5>oP#<3S1U|arD9B4?Mo8d&{-oOo*EXZrx?x5am9Z7ZOQ5d7jp))wNWE(CQYFsDb z)89CI3!ug`Wst?bNsso(e!(H;s*l@&D5ac+cK zg{+v{Pd#YqXZnMxsVM{zwXww=^+(YM$R?`4#k%s6gKQU3H&A@z+(%R^;;j&*%W4nw zTV9v(w_oS6t%@q3Il^?RTFnrAZ>sj7rW6{QsXk;9>ush2{-z3iGFN}GIPjI4lwAbh zfkF#4+9IvIqyS1Ru<6M1lFrQ2QbnHzI`^!w$u97ee=ykFT3tldPoTF}Z{mhNY@=So zpq9n9*l^q@_-d_lEMH zEOp_VcrIRLe0ES{2^qqo9MxZl>G0Q-tM4tQLH1&^3h>HF%_hCsC};JPK&)Yyn|hDB zeT3HTs-yLiryMokGW=RA6#wra&0Vc(w`3LyrSY$@seDVy0h?i)yPAl%wl*Hv4nPMF z)eo;LGdDJuU1F@r{tDuIyn&^jYE{?Ks9@;Zzc5sE<1zwruxBMDcvhT( zs5WN4AbY6^mLK##rq{6q`gp0$EY_A69e~|lYE_Gr@)CSj=cUF7uHpYsgUx&nw|-n+ zSEsnVQGRiG4={>zFEsa6lkr*eUSG8pc>oqw)LE8;A7Bsl{qXN8eIi>|Ma8H77(wNy zUgs{7>*Z};NJ>Jx>HX?CZwvYW@Lv2DVTKxHA&_lqomfF}-Dlc*W z6S~z>t67}A`2$JMX4g`2tDsvpQ2mFR-GuPkYG)pVQ(N7N&t#j_QCDCnOi3LTfAV5H zWYtx-(uVg~?RqNy@Jqx6_$@?TR@LPW4g;TV8N6HBhq82B;{*oeI;`4tyW-~2rMQD0rk=b(Y=OD3>M4b+2#Y+$irD&Bh> zf$9yBY0>rFP>rFD&ak3}YD-e-)(Jiuen~tq1CN7A`v+kACMn(VTn~hrjnr;b=nr{~ z)T*@QDcIgf-GM&hL6K?=(<@iX7FwG<-5zw2YGtcOI1>J|&QEq=$8NxbNY$@G)KKiO z%~?Z-H}mqyi{KuG4EquKM&S-T0Yy=&A9XkZ&!W`5H1;TLY^>JDi_eFRkvbApP`&kO z1N9~yQa3se9?@zL`Z(G~tN#3II9h$iFaKkZIq)bfh{Ybe9A?jBRUO`99AYb*s;Br1 zuH@!w7aon&Ty0Kk9RR0zbrkw+X2h$3vajD6918!D!7GM4Ohc{+f7GsCynitU;6!MI zi~4WuUK_O%rPa5y(spVi6<<{}>aM0XIkAcU`#$h@%<2c=pMTv4uEp$s?*kiP8A`9R z`@kIhtpxQ1MiC$Bp}wavo7lcYRV6fXJsj()4iU2UQ~XsqXO_@Q^`$n~*BD664WTi9 zB^blgM;%9L^coQQtDSgYet)$ackJVb2CN3hB=r~y^SeoEBCWZVMGa6#8h+Dspo+#% z5B6k`8j8PIwG6z6sBL-l*$_2`8~a1lJv=v5?M^Uob*TD+cW)S0#bD-eL>UPehpUMe zp=*t7!2N(wTNLbBn`AXvY-hjdNc&5@L7ZIok3p-#?wISdEK%QCo!i$&i?=5sX*D<$O{zgt*yx2(mG`2sG z_z6TH@i);T@k4+-C-Ow%75w`e%~|JoCgH6oMdC~&@k_rmd5C?bH%~&I#UFFGOGD-`~5JJ?_{g;nk?7d8qhv-4EqCun{nWR_8k=Nb(l?A|H(= z76PAectVywjfC2!pcq7Aes|w&fPFxMKV4d@ z^rvYD9QA^$W=Q%9nYe7~24x*^d?B@7Kq)UdB5+W1K7#OKNf^A}2-xX~3}Lf@{QGU% z{gT6r%9Q`Iqk^mUK}Q!XgXA4_w8u%`*A6;**t?MDprd0m3B~U@@!4Osxn)^?!}9+e z*7wh>P8jB0{*5}nf(8>i8m~C|I$n)q=AkR-`Bxl^95;sw$j{yEkmHObMchl@zlzF& zH)vl2XI#hn8fwSrN%Z<_j=uIj^y_O7+l-pNj^5y(E0p-UBdzI?#%TXSn_L=&7W7<_ zSP3lh#0_!3^g7B|>N|ndHyj)JrJ*+*m$>se3>HEGaH0F?TYy0aG|>6P zZ^(%d9FLl?f42RPNgwN9T%kLW2nKes9alzAQ zltiuXuOCir-o{|)w0p$cj^6{M`on3!`;NspEBxyFj&@Bu3}a#(m{|IF3Xbjat+Y6D z@EiT|z9UVNhEdXS$7DNReLwEFCG&RP2XGiHHTL?*F;C*Nejhs`8+%wFF3Z7!a!QWp zBA`Ng^;6t_lZ_$Kc~7@K6#9uHg$KHyIIu8`_U2PZWYDgWzNreSRi#6`AW;b?!jaob z$BE!ida`l_#?H)7^yp`fI4NKRxj%!u{NVoVGeK=2^F+>#n}b|+M!Dd&B+OR!sWI(o^`SH{)hJkkt(oc>{O zjSdc`LEkz0O8xp%_IHkMlG5M(+IJ`n`$D?#U&ougp;!(7_=8SYJ6dC_|8lh>UK-V% zT;IbnqPo+t?;SH6J<|>YFLCJr?nCDF72LZ8y!~_c_uu38U|xd#5rgagk>v0=MmPSs zEo*{Z&?=sufZRWZ=6TR!|4r|CP?n#Phl2yu&cScs9gm5 zQhjFA6F&>RC>8j`Igh`EhzO>sHy!%}lY>m!+0^KkV}kRi24;+Z5IuCu@jh-~=>0c_ z1StBy(Z61&6Mtg}J^fXTRJx+}qRBpYW0cJ^jcWgPtd-txK*#=Zgt>RzhMT~{%KmXI z@k2d}!!0nFdwn9wqohcg+W5)hng~C6RPbXC_E|m*F_cRVuTJ^N{{B)6i7M;Ml`!jm zhumBmWT%r3`EIl>PFWs{)KY~a=b{H6ugY&@p|4S(yh%z7p?!h!TlT^3nNAt6s^L9{Ao()h z!g@Pc9%v65ded&}UIsPAiYkh!5r`5SxF2aGcf<7rcnPktoFXM(r+*sD1Ed%~_ka+& zluxbHgvyV>A67PzbEIL{=ynr%5jHR+_oAlqNqe(H^(EVescX>^92(~LfpH>k{94ae z8XYcY@vJf&7nkEycQZh^Znc@*OLEquFPq7+_WRsQb9n(T**@Gtepzb$D@8@f&)8pa zzZW6z!Ik+mvz1)h;zJumh^x6$+{64nG-RY@L69d&Wni3OW&)VH1d62Dx~PTt}?T@51rKua}k zXfH3azfU*X%M)=(ZfvwXQ)+pg4oAy7!hZkmU%Ab7&+8!LhOl?(@s9GFE&MNuBIU20 zEROvf%kl-rPI#j$G^G=i{fR0%$y0GeuWM&{suWX01)b$teB`OKyc9j-+%9rD--gvi z#tD#?U%5Z&Dx)dA?4BGeeI1+uNmvA5)LM@Z-xpfYE6At+G=XRTS?M*n~Bj=Gj5AN^lC(p*Kq90(VQvMMO(ccY# z!(rDuQ68_p|E=BjCfgeK@^l#}SECVYG)R5~^>O1Mc>hXEYLn!9_-yMC`A4b#N%w%e<)eOBB)d6G9%`5Tj=2YqkT+rPoIW3knu#-^ zDe@@xwG?@m^4&XjF%KKDvbyI&h6ce3+Kc%$V=+~4AtDyKB8!J+nH z`Q<2iv4!V6MKe<2=YAFR-&DDr-wqxlw~-=`(D^ZPgnh01@)-FF?jofx(&T%k#%_um zkGi#jmX4Rd=SKY=*(=%Jpt~o?li1fM$oEL2O6mFp`Mf>HePW{gjvYG(Ywwkx_LI84 zLSfV73q1axCO?i>wUu+W3%LEC8=$(yX_nq z4ftJd-0d&BaejLEJb9iJdzcQ-lV|WQ-hBC--;5$Vt$CtePe&#?_w$}MMefK2a-0as|l&&RYD!OnQybCH}Tb>HC*$&_({C5{{~ zmfONto>&Zn;vUV#@->NVl_mG*Q)gN79KWbN_AO7;3vd1WW>JYkFY)RwA1C#=58tlk zNw#|p{ISn-l)F@J-)M0H95r*haMV?dD008ERNl+C3Euax{DPm<=P3$YDbIm-EnO+M z3R?1{*4%De3EJk*(DN(7WNddZ83TdfK8+D;=(IsqKSl4Yms`8DACq6V zI~wJ(h;a#2l`DtPsP$-P4!Pf5FW;(%_gIc@l%Hwv+e)^v)|)sVch4x4@kx(1>uBUA zxyU}zjVt>DC9VmZW!%k)Q%YOqCH^?cIQ?1quExXGG4~LReFNiC3WnM}3G3*UXXWKl z??5ctKQYVyz;lR|==29>`E7^!eU4dvC#(RVhK$^S)&^@R<90&St5mZSjmEHr^yV(P zrPN^|eYs0+Xa9EVb8_cKVGHfzGX9Cp;U74jfKAK(&&g$yv|wwIJOCFoCKkz9=Rw|vIGmA_+q=8&LAU7N2TLtvKP)#RSFt?P|LANTbQcxNSh)F$X6}=pu0M1Z z5}q1L4c~;Zf?trA)^}(4#1y>%T9CfTr1$wC@VNw#&h&{X;+XcnF|h{_LoDv9HV-aZp8JI{FkKp(zpYT;2kvo?&A!i zE^o@XQY?0=yZ=!cgL>S;`xc674~4&tU?xpMAhpS$?Qf$d0?DyJzJqormFB-AueAT} z{_!2TzMs@+1YIqY2TJM)cib@<@9(T|4=k5)ZRdCH{1YJ2G+3^R#`{>d~KJiq;E;|+(F?&QWw2Z>L)g(_DiNvWm8E=p7P$}r^uclK$`lvNIA zJrrL4xG*aG*uQQ4R{Urqm}t~%&$J| zpcG=2kfwK1q8puf)Nb=^u7>aeXSZ`HHcz?xnNG@Htg>BZiBsyg{KKYgZFW(1+B?&L zE=n_++*KJYwfNm#)KzIMPQt}1XuNmf>wU`9`cMAM2`VWr4heAVAWoTzmCKlTrCf4; z;l38H#Na(1it42duvfWf^-|LPq`n{1sXj`72`j<%`zqO(5s~44<>hXJlnANiXYSNN%5guO!U?%s`L8UcyiGSoD{o0XE9h{l zGLBCYj8XRZl`XMR`d&vf_n9#Yb|hB0Pmfb3+5JYmZ=)7%9bt6WcogSa>VA)sj7qce z9tE2K7>G|)<~MxdbuJun=vaK_4JY;|D)8(g8gehtn_s;XjmvoNRdB=_cY94%dShbS ze2Nl_LEXqH$TMEno1z?TeEA@*NGih%9_vpI$?+=~?ZQgvq^ZhyjFAsbRUXA{QQfC0 zqp|Jq=rrZD-{F^S{8fQwG<&*IjB{*(>B?m3nV0C^so2|jGF^E%q|<@A{JaM=bpIK! z_|pfdWUA7X9-E=e<2C5GX-W*Wo(V0l(!iNYi4?S-{+OwZwZG-Q`#xo+9lK5YGn6Tq zMc139>@~70Kh+52t=I_&@`N|?3tNwPrvYcOHU!Y8pMm$IE1bUT+8E2;KcclRX<2Kz6%PdupL`u_HN zXy{VqSNlG9uZI*Yrj)r4KCHaz*IQ{X@r5`6_y1pp^W3yjxn;*@+taI*A$W7*%T;JK zakOc*GPGfBzKwe-W5Xfj3HSZ0l`~Skooz5)EOz%;ql}bzfq$)%#?yke$|$MTdTO7m z48^Q#QLgeV2K?$eMZybvk5SNL(6Q%J?u5q_yzN>`KRgb<`j+agS8(gqM)%nD$~t@a z!bfdl`m$=GB(5$0=;V<1Y`C`k5BJp#utvE58Vw&Z;ZA&>ds05s=Y54IlwYI~Pt(Q% z&|~wXKzTa&<5j#R$!8T?aesB0);_5e1W&8f)9_qUa0g@x-I9@z2IrP-2GRf(#|g1@nR)M{NZl5N!ch#F;9?tv$74< zFLjI3)_%jCwMCg|k9hP!VM^|1cH?K^xCMIB!d+D=EnLVo2LNhJ_KhYHzuF9)cPe)WKC{52f6aYr zr=t4dnwn3Gl+Q7-JFr{nfF*>=-3kugC(osS_9%1gxXJu^?%lA1RjmBU?}zSHzOp~( z-nUP|UHADkXg{#{_Q8Io#=gh>`~l@v9FTL5e_81%jt?AE)?m8R;1y*dTAxQ=QSePQ z`so$LV_)p9JcP2AQYO=l5_A{1Eaz3_xN^@dO=)eA(i{h)osT7<^IT3zuPHH&zC?%b zd2uScfO81PU!ZlbDf{rAf9Kbgh;Tn14{Qj?7!1t0DS~Pa|Gwp(`MUBWI(-^`Sh*iJ zd%b#C`43bmbSqVMzT4qVWs@=&Y`Njgv{UMj_;N(sqe^PaNeXjG!x2P`dtM#{0E0MV zz4>c;>ZlUcWM30}^){g9%aLM}GQWaJ8#Sf#N0n((voX~9E#&i>JM}G8YV?2O-d0xI zyFAEy>$vFlzb$OGpAt_6ockPi#b|E1>^r+{Nd7=qa)BlZ$JYLNM~P^9Y>3Tv1QTgZ z1zzPuy}WA@b$?fRBYJ5Mzy&EqwV}sv@~>&N`~^BY$nygZel`+mK0?Gg4+Xua@Tx(t z_Y{{D(2^#-r?iavtcr&_A9)V;gEFgcXGhh^dzQ*Ruh%Kvf_ z?a4Yif5nndZRp200OViCa3m=AIVk+*M7$X4`E~?K1N#nWOZ)J_u1;t_@ll5CA-HgP zX%0RQTzfn*A->hpzc5Qh#&d9SwI`w7Exd6!J_qlCdv-i}1FLA*x9IUnj}%+ZYL_i5 z6qoWu^Y`v@=vM98>$O2!%GTfQV&)&U=4I1>BOav36}idD1rdR~OpBA?iM(-vH+RK> z{S7hNsd&$S>^6)pK#-EdUy3O7yc>0^me0&5vV3H1-=`yO9=U?19aDO?S&ST>_q0Wh zSXi7Uti^6D^J&6Dmr?6uO1mZ<>M&`@OtNU|F{M}iudg$YC+XQ^O7q0+3eI;D- zYfgRNSK7s#`-Yw2oagyIqL;xLIsNzYs8Bx9DuR#W|d-DSTgv%#KB({Ls%o zW=A{cxfjKo|ApZa=RCukvrD`VtbJ+IlK}_t!|-I2JE!~IbGugeBcJ#RPhK1=iYN^! zv@v!p#dXy7f#iQ2BbAmk;kdFGOG#&rE3r-DeqeV9y@CrU^R)|Ll53;-Q&2gEIze=Q zxiT9UZGBj-Opr!jqlw3rXc~P&>EybXa}ih07mOFd`F{@zj;d|JTSjN_GvMzQ?(HX( zOuKaUBMLmF?6P;Hmrf}&rBffegH9`|T{`*!wK$`EEd6+%em$eSgq^@=&nnr{(R0+| zoWf_RR-OZR@htt#;0l_2URl&iIa^nz^H8WM^JQGNsT^ZTA7BiR&pUjG;TZTLkj|c> zz>kpU3AFknn4i$$Vu;2 zLK<}V4D-15^vY+L)h(nspQASqp<|z;={-o%Uts>0LhHX!#!5HerLVt0pO8l*E+7^e zynrkneTQ0niAEAR{E~C{HU(Z39mmRx$kWZY=mR zDgSE>DUQBLExysfZ@}W>5&HWZWPCO~@E_#~>7tvytx}p(({GiN(qD&hRuo#JB&w80 zrN7=_+%C4~C1s0rtdv5(Q%*=XU#G9X)1>|v#)qc=g)V1bqrhs6uaU=UWsh|BRSNtb zMDu9=_sEVy=e}1qGUFeh(&a;RnNm) zJL&K1&=1DF0sSuTper|^{XA-L3qJ7JP33Xv)Uz!5E#`9z1&8eZjp-7c^l#<6R`Z^z zYp&3s?|F4pvRMrzfO?I{S)FyrI2Lp7x1)=YJr3sAoG%mi5ch2~PN(F0>?0Ie3HSrSK5+>0i z=XiEfp+gOkqk-euK_MMV&lwu%-QFBBj4U^TF?3$m! zXG3uEhKacFFW?7;*5dZZ;`xermWbzT;#n%5hsE=Vcpeqc zx5e{a@hlV1_rU&e&Dd=!OeUPp@gH1PF z&6RX~eZXH{42gy}K(Z4c(-jEu9MJ$d?hK#w1v}|&FP(L&k?nSIf^Qm$Y7@)@GloA9 zaMpuRei=qU@2{tj25JxcU>e>)ZQi06NYJL@y`!Lp0o?`A(Afv!$pC*^(?D(4DB$~= z+NH~r?+Zx44|w{uYI?JQx+SHn$M6>|13CTLBF2{YWtM;LCrcaTo%38-^=B=D?-2+q z!L`n7w9sSd<4B(WW&YXZxX?d-q-TTFmeQv`(py1l|H!^rD8d-2Hnb0Z)P_b1@bppH z-&Q6(6hj*aMFgvn+07x+a~2j$MlDT%7fVo`hq4LDYs=WHH?fxa0Y7oDAz;dn42gkm zKsusM1@uG=^F0+1i(hQ|QvrSSU)qfv^UJ`{*_~6K!aWY4u;L^0osAx2&LjO@Ml9;2 z^V&K+5-FEcb+9@lI}Qvyn3W6p(a@zCs{~D>bxjYjg!}+MP1CF&Si5`i)U->(FHO5? z`Y%HsYj+RgaGh^#@`ez+(&;>fjcLy)oQ4ptiTjbdfiJ8HCFq|^NW~fL%{QNw6WS_bU)D4hSoq4WGLYYDYM@ z@vZcj0XkQ4{JJws@jJk~21=Yxjtf|Z$hDUSB!7AuDaUCkqml54sBpjU61f~~#a{(O zD3y&XGLzT#>j;D`|I4MgIy!*B62;(w_vW8YLbn$e5aP@HFLrBPt;t-G3!HBwsy6f? zO~|(Z;6?eLqNLdd1Wm9|%?PX}edmDXOU&js5dcP1Ujnv%ZBo zLF-CkxKjb+sYRIDChmUr(gNpb1fBy9(IR&6{QO5PpMrEj->w>dHayRh3;d}7+yxP) z4pAE*W?=2dwE=Hd)3Go$23HXO7N)l9G^ZN%_t)QRYZLRXuSff`w%?fljI>Ehdw8T8 zocfqs4~#D0fj4ofZ@47(mlA*M^nYtLq z@2@sfTVWwGxVah@y#TFZ@`i!@F{v@)C)y?cppoTT`R~I(QIy&oiTa5|+3{AQaNk65 zC9D(hA%i92qfns_+HtT~MBo-EQ9?TYH4Xo|iQg#jP#NQltjI8KM}p(ZP6hn70@g$B zhO~!6dY74b*li#Y~+G|hSlQ1;F&!6fiUVE35I^)2>qidjSRr z#Pj&Y^X+J~WcgWQq!&E>`1!c9-T^y+!n(=vwNaroKSCXiY1ms4YB&z@d={Z5+GD6m zOLZJhn9Xacx&jYuWrxG|Ty5wJw6&$$6JLG$prsno`FE`1Cg%?cOWrU9L$xb_u$a`1 ze|xmw1t~5KuG&IPTB*H**FCJMl$`um1sb3SXnHF(w0G?Zm}n_loERiC76+n_^NEk! z-ylJ4)JY(CiNN_YVDI0^9t!`rZ|GnvwN0nb5O>^MEkPBCc)Am>`t*AO0k2v4acc?o z=eN_%R%+Yq*%~^%4!RH<63J_iJ8&`C5-%H0`?`WykExCNop?A{BcA^e&+FoO*(d&8 z@w-Gk_lW0a@qA1?mx<>*n#GK!2w;?Wu78GOB=M}?#=n0M&jb-OTs*VHbCh_-if1G7 z^cTmlduz2it+q)*54FW{mK@jUrKO+dzjRaLj^%LapZDqWdO0A$9Q87G4ykKi!DLEV$-TY8Iuo>|R|*UTGb2vu+gRiGnNsl!n6&(q>%XBjM& zI(UBzuM2Q^%iwKRC5kO(4_N2(^!<&D)~Vei}uQ)7|itcOhn=CFWxqvn`z%SBQ77D1ZTc=7K-3r zSs+^wq**2t8kg_5lR=V&pU48zECv5>;dD-KWguItgFZPB1d)b@`FSsL0{<*iA%XYF zfq*mW(3dZ{lYX;-*DX+Oz}fkEM_54dgLfvVGzbg@EPNu4yM1(pinS$I{Q8F*{79h%RaetXvQ6qQJR>U#9MmANh2a(i@=GL`d!r+<$fc+69sow+kxTiu4?On8H@Bx=jWAi zrfZB$2MZ#x~g5JAF?PcMs*F$IB42Z*mko8r;B73 zoXK#A0q1MOy(C&5qeh2~Sa`E`Wd6EsAi?!xSYRl~!B0;Ty&0o+Z(`T5>rHG*rswuN zvd5~i;cqSQO&M#XES*QgW7Te)qFF(*$q?AZ2~(4Lr5!AHFHSmuJ#HWVdA!Eg0wsr z)1>b3iXeKjyW0J(D|1;B!5|TX&}7d`b8l&mQTQ_kq3ol=?llX|;uUr-NAc157`0WC zj|GQycjV=R+0-pg?IL|Rm(t_ZP@F`0C{FEVA5E{sAxo;3LeG+EQVi$f)UMbpiH?UE zK08H|;sNfZ^#GNnr><#g;nU{vYCC0!0k04+uJ;e?0kZE;>SWERbq}?j^u|eA-b3xG ztTW<|(i+6$#kLDQ)NXhaQt1g#&FyB3`7sgA@xOw(T2%6XoKbC5}^ zda0f964`-XpnQ-n_EIOyi&kj^muA#I0ZYC2&b^`OTKgl9O~U07zzaL~mrg zq7#kkjf|($DuBq`K@Jk>8%9=jrUrdb6|Z)s{s`?ZTG$6z8|_3Ol?kZx(Ezw}HtFm+z!|`>I{DYqp#56{3fK&KzttF#a4QTv%MJ>fe=t?5tOiikpE3UftYXlG87rxY$+@RR=;K{;9>{&_3piwk$uFJp@pUX8|F#dFC$N!q2qFyv zy#vp<&rBeydYT0b1+!+Gcp)%t>m9H*-vkYs4uLPW@CjlNm;08PKrl36eN>ob)C7Hy z7ipMS$7j^RhZuNWV49IZoj%Ec3-Yo>8P!c-66gthOn9eBb-a%S1-{%6pyNvoSXan| z^Br1iT0EC7_EX!3>Gb~h>jufsn`7e=qBH$b(~9W^f+m}dX|TD%;qX?xL;VM+!?6pv zVSqY1)SUWi3{V8IUKjm5K<$)WZghwRT0a~=)s&SgWErq->|D25IKo!`b?~J|Mc3(l zU@=-UVJ&ajUbAJ;6QtDx}tK5L~;a_B(^mX~i8^#pIM}_l=5W}x5 z%OADjn32P}rJ*ZaY1CjHuC^4qDlBO66L9qu9}9AY^T~lQsShj^FkwB%rU~^N=hjhR zv!#IG7is1Af515N1%I*#((Ay9Z1N;42Lg_qLV1a5%Nag76-DE3(FwSEvgu1j%(whr zGv|p67aua=XohpQnT@?rG=t6zQt{Fq=E=PAB96*oa$Lx?k*mzKf;P*5b$o^a8-8fO zI=-cH|E|{S89P!5Dg`t1}>kQ1r4MPD7FEU$N2M(b#gE?~t?#!H)af`N$ zGh1x+3Bpk3(ojKOCgZCMOk)T*%c2)8XxcOrueGQ~!4x#jD-r^pFbQ%Pj-(AqYFm6d z>0pxDT&kH)XOpndVxymv(6y&NLtWde&2Xc9?;&bTqqON}+C)xU6G|(Fs2!VM$2n-D z*z~y>8lEsL@83i34N;p9*aHGFX%TrYx2lEcICCv$68OrOO)&z-OPY79omx!1QwpOX z#RVxpETZ(g(M)#1HOFdL!{U4N^!a%utR_atcdMg^I=swVME#Q0 zMU7&2i)cMN$=<7z=-p(sBd!YcCaW7;CvCXX7?GRJT%?1uH;tYdrnZ%?PNa8-sm-!8 z4Dx(Y+{je2*M;R!6iX~u@U$0Ab}mj{38lp746`K0ms%@N0-yVeNiQ0N5FTHErfBKSBZ9}A4wX9`)Xz>cQ8mUGE)b{N&+-;lYfrb#r0qfNMo1tb{*N!@GL94nC# z{lREzJxXnfL#c_Qu;DwE(h*1>q|+LXO`#%=?LuW7IH`(}KaV2&Xe{M_Fp63ueIEdJq%FI)!b*f;V2oNOc95^Y5g9s`TG^6PlXry-@rAth^ zYU>yv3&_O*1ac5T=^P-T1{HCzipn@RNL3sZY#oc|PI7UuiW0}FVZF1^80ePg+5iX1 z^rntAOW3>F81n0`u;y3}+wXP?Jv9~@eUn13a`^KY`WPXYU)7igkAsW|ishgSrE;)} zGC7z^h2zw)CLz|MOra(?+FS7$y*p0r7#3-jPCDb(91|6#U~3xEFC-TS(4Pa?S&Pi! z0Cwg8cIE&jgh2W^ne5}agp#Q>LN?fVEhPsiAr72W#KBZ58?T0C`)@O?k_ZL7Sym&& zFs$V8x*j&IB;cIkzOc)isli-jHTuT|UWZ-YoDsfoqBqlm!$mO^4z&o@96Q2#^rOss zpk4+rMjOh}HN#9ek>O%gQJ?ArZmC||D3hBv(n_BWdf=12u}cMBG@1SuejD>kv-ojY z4HXFzwRS)+youhbk%B>y#xT}fm?GdDz~0>Xrb6pAJhqGW(x?d-rBn_vamh@ZakmMl zGF+3a!*v!XQauMy$;GDSq0|4e*pcYDh*c!uFMX@ob)7m zW6_DA;mOgIPw=`lSSX0G%0#qmJ7~s4wW)vUY&2a9CaP&vF;Q*1P~L)sJ_AgVBEy)Z zneD5?n~rf5B)B-so8bz2`7sQ0M|)i_2z)H#v#i#PVbl*t0HV=$OcJh~GYL)-pJ6f) z?R)t~6Xu%kjV#n*W3FuA^`XDd9%?!Z;r;oFx;~yOl;TVYe7Xhmetm+s0)>l_Kqd=J zvIH%ePmEZuVVRAm*_1e6Q6FH ztcbc!QM-0e*K#UMpJvR$dTeM}IYn(^PoTY1u-e#zE=*BVhh$ksWeMI~ zV*;3;r%R0WmOUpp39s^Lniv*^jC@apU&d0wR9L}A?@U#j_pG+6IlCta6TKCPgG#Y1 zsNBLcEEtvCO%0}@1;?)JG;m6%sngWv;Yn~x-_}mA4YvoD9H$}`qA)#K*fFBjnLIhG-0_~cCMdzvn`Vb)&oo_QXk-E&(oQ;DVD&U}q-a#Oh z^`c*9s*&w4ml(_B6R}L5?zz}YUnU>$4);v@Mn)R-z7I2Bxb1yv^EO$1biMS6kiTJR zbP8ggaTjgBkC&|z4gK>f*uvEoT*z>x1#1%|t3<9czSP2};+UbgdVt9f-{(SSW8s4t z&b46nm~q}Di{Ua>EZ%}eD`%F7wuU4O91I%NWW8pGd4sX^4Koyq`O#mrJwxph{TbMs zT_d}M@uw4l5lR4;cIn zyuRG)zs_VYnvM{x3UCp_M(LbBZLJ9xu{!Y>Jm`3aVZ&f=8T%1h3cx&|z|t}j!Lisg zy_=2F(f55fTaGD8uf`HsAq3tD3jyhOII7EQdq@aOWcuPvQ=w>^Egm_>*iLAOwQknf zw?sKX(jR7mbcRd+prdoJTy+FTuMJPvbFX{I-BddV9)i#8&Q;@^C;Tg2Fe7`-O`n{)M%bMCi@vSiaE&C8_p!dI{G4_|9zJ7LOG*r(Ki9Bdzknf z#`|08MRC{oc!x-D(%09P_lwCpPmLK;Y|&|MV#%2!+}_%KPh>tBkC}2AhG867shT=p z?KIH;0n?s>)aP_U4oJQ6aV9>QS^FGO#nl&p>CAjAm!%ChC7>M9!jZf{jmyqKV*pp$ zhA81$nRmdc-gJgF-}9zf4Mz@_yiY@t&TzbiPh>dKf`zM=|Dvg&Hyrtw^M}w|3)JxD zr<3n+4JL*%ypq|DeqDg0XG=gJ>P#Z5?y~`gco@&{NWDP|v8uF<^)Rb^L0*-hH<|!_ zJQ8VHa2eyxwjBB91`nG&3-JIZObh| zB3;PiCZ8Op%UW=aCO@D?G}>$#T6AJb3n&i^231<|qToXo+^J}i#W0Z>rp+-0h&7Gs z`GQYdpLiG3W&F={)M=3#T~A;uY3?GmRd%^WBpSG+`KIt#W>RY5*|EIUXx#J`R`d`d z7JU)peRekmT-?Q^7wP@)g#EmgJ?P#{wPmAH%k2bVX-^uy80UP8t$5)FxpfrUY~e*Z zA6U?5)uH$4;soBuN}7U}doy0bV)gz^4D^!d41(-5W1&><;5NtKnZG0pFYJ?R;e|LK z@0i2(GN)&{+h)u)?Jw~DmipY`W_!!Kn*x)WLHt+~Ue9or<;FRp`dSKzv0bi}K0)9u z*u@QA^&C?Hkv_y|qtOS5wd!(oK3rw?2E*K_R0z{7dEdgarA7TkeG!rIr&WrO+IEUdR`tl()vBiBo z@cMpo!Imn-B^MN2g-p+)Z?0pw6q-&w9#;Eck0bM8j8sb}Q{KbsU5=9;fgDO7KCJd{ zQ_@+Cyx2X($Se3kUwE0fWHEJJrasUhc(xdUC4$adx{eMkQ`<;aJJE;B)MjIA(oM~T zo0oSo;R}M^g0C}NY{AhC*O=8pKUq78`Ygv}2eb3#YIF1$%a>!W?!q@yFwei*kv?3m z_6*(D_m0t80yD~@@D*4QNTOi~x*Ib@xLOHglPoa|BfBnd`Z$p-QQ68b(Sa41BAmwM zI>rzlX&J|~+WdEg+B-WVO3$CR@a9dk;6fqDg4Z(~Z^7Fbj` zS%r*ZePT7XIkCpG8f!;An5vz0xEB?!R^yvohw*(%H&LhdOmahU*@^CfP+VX^{xSXKQ zADs0nyF=DjCU?;!m9{lu(Jd8Qu#HP}vkr%Yfr}$@ErKJ=5EW{T+BG^Vo-M7P(D9Y6 z3)V{kcNVTuW2K+l(8p`k^ajskxP<)hx{9avJ{s_-+RpFcsW-{KR&7ixA64&yn;v-- z@24iwjYrj)oi5?95^i{3Ut25g1mUFIrmc{4Q+(vf{_r1e@&aP$v9)TO?2y)G_QcfF z--4I1v1?o={s_a>dLIO>quJprExd>E#)wZ9{I;Q-+JUfD^w>& zOD!x(8`fhLBHoDBdE%tydUbTeYIFvoR%%-@I3Cqu1CHP))A$WIlhcoKHmKbq`Zm6a zDp^p%wi}RVtTp3Zg7LAQ82WI7+N@E0QI#Qk* zFCFPfKjf)hqh5q@jXDMgEn}@WTL!OU7`vSL$SU%fuh}gh3@&t_llf`~>4Px(Eg#Ju zggv1N;{d`qfG`BoH%;l%6KYebyeZv$0$E!|Q3b%xq45P;MhkGb9S58W(Cx0Fj|n;>&;eBm$AHx#6}JmaWip*l$VVKhBZsNU_ou*X7mrei?08;!S# z;aAb3+b~+ba%+frS~wbYcuI|lO0!J$B1fk+)^n!4`^EJZyQHh0!eH{#4~6DwbnGd0 zV*G;|RDoBgTDk#K&!L$7>A@n}Fyh$A^r!sLt=&LJf$kS@uCRz8G#gJ?WxjLLy zKdtt4e0tu{_Gz_!H=otf3Q&5pPMaxX8O{wfVc`)N32auob{OCKe{a^9kKU|(-kLHu zW4M(eo5mBpOO0xBt>B6h5^TbSqGDO_RfcPVOnfSL4%HT1!uU!H&SZSK4jcO-^|zpP zUrNziFknK9u?1Gf@M#NLjOz{Pth8u zpz>6PihCgga2-AFg82`adT=AeLQ?zry|(oh4cv_{&Q?7x`$tEA$TyMUI+)NrTnO*k z@J$WI1o>OI!AxFzGk8hw2`RXrUj z{uwnY=IQf>cGvu&T@AmAQ?MmjlWRlZ+1=2RJB!voqb4R~xma#HTv@`So2+x9#>V12 zw0L=F+<3l=B$lrUC-8Ax;bDCG!JZz5nu~zgs{+7)PINCEX<#U2>19C z7wvkkU10Yp2$)5gJ5VX(9i|UYM83Tt^-MU2eLTck*jdl`ngA2e102~V7QINip zYrLFo?bpzXU23!Dj;5@QHXG4tIkT88Z>2-K)bK{-c2m#=R;1KU7k8=svwe2c5;?wl zx=9z!aJ*6YIAq2OCs}-Q1iq_DFA6)-fb}=0d|KoeIfL<5SyzBxTW^h31j75Ot+o6r z7FcPcqt9Ui{6;8wpTosGXqb!Cu-3W$rd;8KNfsM+Dr^ul=)QfVrqBL4*yOgo>)80&+7 z=+g6Q)A6|pW@#h}xBkbpSUSU-Em%~GTniRnm8HYJiXq>{m~h0?@?s1Cosd+7QF_g7 zI$EqoNgv#%OT`!vmfkic#j-uB50kPN>sE7u@2C)rNPC9i!-6Y)_L6cv? zZBzf*_zx<}Xou%B{!uVG*wgtnEX?qaw)_KEs;hV3s686Sr2ydWbaan*+&-1>R7Qwv zu%30G|2%&_-}&co0p}a=Sq)xk!1FZv`GB*zh35W`Z-K_eiFkY4S?(EOOTiVZxZf}l zmukHM(38CDR_#&tKXi}99P!?C(V;pQF>o(NxS$-zzaMbjs0}(C+6%uCmB3NwY_~Wn zM2iaL01HpvQbe~9U4*!>FLdvZjKGkq7lh;hpxsZ^Zx^xJ$-{zJ$m5IE6MKkTZv#?+ zKN--QzIs`0(J(-;L`sZmoK>8%^~e4$iaw}1TYM3(C2)%bMR>CG=;O7aKL^s7gKCq8 zQ-AQ0n*cz_ZMiFu<)%*4gtFvQ0UzBB z2Kd7@!U1ml7KY(2+vy!js}8AghfCD3`Zz2U?pD9BUvU5CRv&%~2j%8I7}y@cQx68# zh8J~KxgD~l+npjb;3D__tiby92Ci?S;h)sQ^otHbmuQnUdOf~mJwD?CJ}>L=YkGKu zep?aPwBdFg+^2_|-Qg<(7dfEChP8nk5rpRkj_z#=*OmI;V3(pP8@nPvQ-XHq20l0G zea1?J4`Ss13;x@OH?56Vp-fT;5=P}r&`1ku*huF9C!>z& zs4Chr(%II2mfjrcY>zKKT^i}+4?o#coQ)BLrT}-9dZ#!C*cZ_96ldRv;O(l7yE^{s z#g}m6FI@66b`EIG zq}O#)MmhdQUy8qQd{epJSSj`_Eg$V{)ta?V{=m{YeYVaOAy;8|)@Wy31Ll?qG8W*P zNjFD3o56^osm_=V%%Kb@z86@a=joa2qH%}?j3KWrhZd$f2McnKkHAEoJQX1eOxMW^ z>C05-2tgkFp=x_n=Uy~dS?#u`X= z0?AO=&auu3q9E2IHD@FCF-=g>SPjNYW@DWrkm(P`I!7aj7$<^-AH_vOG)SY0m$OL`6Er7N;e?N)5+5o61~XTw436+jwVNDL9Vq z8Sfm_Fb-6#wB>*M#|wTZ#>1IQ=;!gy5e+}>UYBUtJ>b)!5Jgmc@CoSmZFF*T<EhD~txg2mP#5EeTEDo*H{sZk{Au9Hfi zO@Ll!>GlNYvqAyK$GVMZ??h)WQMR#&!t2F?K_*n^zZ`^ISQYRfW?V=Clbj<4v7!?} zK1e4IUZ9beJ*1J_05dtmY$k@;iiN{eaZhUS0@^gmdB1S^9PsI`^Qnl}_*5ZevFLy9 zb@uAb;$HO8j@gPouw^o4%i>rJi(|YHM;q>SPDZxBzt`CpLF>uR_T7pWYRw2?v62&& z17;>>QFcacg|KjZvRoNkk83BAwJv)=OV)RjmaH1^9m(j}Wan%M=`clDddw7O44^er zoP!aZn1a>`dLR(q7W}Dhe%O`&QYn0@vlA$WOm)VH0!r5@;OtYK<3!YYJt~JTP1X67 z7*RttK7|xI&3PaAET1N{*gZ{f{a~6iW(->~wNkZRhCI$VUZ2!t$7wmq1k5>Nm*U-_@_K1!MHd|qg_3~U8Ux;@<)Bg(4?QAV+Haq=G*FH0zbS1~!mTwaXl@+zWP z>CSP&8BbTLwwa)1va$nO@~a3LEIB3hj-BU8P2{U`(dB`AINkk z@^vOwirHaV+jKGmUr3i`IFF0cOaxn2EPalqC|c^7&bVwg_#B`HL0O^qG7VjZkj+qm zkmIWm3b#eb5`E*bg>S+fFSSAoaq+Q$4bx>sx^_|X6nRkZXz z=S%3hT4%r;7tpK>G?-WEjSOciq2v)Zi>@RK;Xgx3R?bk8Lyk9;WV86B_Kj ztYqOt)yDCx9H+0NWFwI~p(F@c`zo3>%ULU$vnt)LMMf=weVGU9#Qn}*Ex9Lje6B^6 z=>v_c2-&v56Y2B&o!xt1ZLQj(0f#r%#wd5dk3ucrc`Dv8qMj`xL$QU$%UtU-&(grz zs5%R1;cRD9siF&gNX{ly_HYA#Dw^#KmRzsVYqOnQQPVEX#(?E4U7PJ}CF;V9x<(b$ zdXA`r!{#{q4k?GOtXEdY`nG)hW7*rb31K+wUf&*SV_&(Bl$$^!%#^FzS|tdBS~PTK zj&rgQlKh31?NsVE*V$HBB2$k_T%n^73QMHYlDW?QBH1>b2A@Qk>pTY+*f7uOMlf)` zXeJgS7{;1-K=-?@iEF;*5U~iEUm`-TeW?i9Yts?3Lu4Wp^#mb%Z3$hPkNzAD;sU*~ zO1PkDR(Mbg_tDe^&Q78RrR%87mo#BH?wvr{yKoV&*Kt>2GV!-o3%Q+QQw0O&a35V; z=#1;aC0Yi$hT-*W;qi@Zj@SG%{nDjWW>uoID$<+F5*q)2=pbHwz}Xb4o_WC8e5PQ4 zoQAgwusP%V+nn~hZ1plVSK$@H@Nh1&zQT3N05=TthuZ|&lqGN*2#Nku)8{HWU-1X8 zWy1yQVeA~bdy%uJ7(u4$v^k43+UG8?2_eHAQtw_Q#s}{p2xso6k;3(fL*!Uc!HLh( zZ;PBw#W*wUq9&ZZVBQwpe@tc_GMyhGn>RAip{45`+Urkg9a`@H=+Fi(c0MeIn}r6` znRIfov!jSRqQ}L)ME@*yjs@2-OY{M><10;vOnsb^gHS7=XO=h*!QF;E=p508{VW!g z&|fRYXEhVJ5VE;qpQXbOI(zouOi$D~mgo&^nQqDogzSq|2-z2dF_vUsOr_RY&I!PwoSMMF6b0SUSr46@a4{~9T;c(kEUUnUO`mXWNEc;i|YIv7ZWe9zkReS+u2tXh6iLTP@>Gl_PBQ411u5?c&i%yD*Pv#$}3)NPHZPva2?(_PRBb7-B0*$+CwBKp6Y6x*}( z-5O^b2fH1bbsJSZKfS#Ryp>ZLKmHb_)9GDjpVH-&*p+Tl?Mk{y zyU;~qhbSXsimbyq9f%xyNtHQFXiDTy#8*%vI(VlH3gcXWR7< zqDpU9-|lx5X}0)h^>!_or_y%+?f%6*sA5q>*H!u8iyxGpm%%bTK*}dHKTz74cld)u zPLq(0B1~eK^GX3}D%19(JH!Z-|IQ!jth1){?zC6^&Oce|lt&mIR22@vRzGex{Z4v# z-|s}hTF=7npSu%PYvrwW(Hs%XmO1`0 zjk!T#PJClVFgzBIjQK-l3J-ne!lEM`Cb~IHp%PM>z3guPisRVreUY>-+QVRPma9bg zI-Ty+ADLypyIT~z%{|zy=BBt&{#V_tegi)xbcjU<+oIhnvb8Db-q5yVoBeb=wIh8^D-#3pBQ%c7Xe8$`jl6o9|U^^7dd()bv%S1$O(relIZ;oe=$u zM(34Hu>H&9c{YE5bW5x#S=Hv>&Y0_8E78*t9rRjsl9Ol7*11oQ64QugeN%SSeSSMp z{LLB^9cgd7&u=Rxp`c+2yAsLJwcGEL(NgbzzfVv0%cQ+%3&cZS1+_hSASFgw5Hsjb zV)#6J&HeuABV?vPz6T)~=0lXb9&_mmPy@=0`H9R=j37Qp&t)>kQOa-EG>KKuaCo_8 zn*HSYUL0s`zm(r+$UHQBK2Rm+ROCiok5| z2mCHFG!AHzFeX`+8iP!jB&)+|nVt23KT`TA{Dn&bQ}zIk-Exn+fxpcD1ZFhO4`MKc zE_H*UtxkDEfxITF|2V{xf7RfyGavL@b)%pX%3spsC-Sf>kA7CIaJ?%q^03|dpx?Gm z-tK!4XP)hmjJ;M-dYD%_=grc6)g|<%Fp%_jpC&^B%1ILhFuK9jC1m{xQj%t zH`f|=D<1OCk_KnBLU~(nzJI-RP7ACs+{&z>?m_3y7puH+zTaFFF7$7lifE04F3hT8 z21YNhidMKx6%i;c1X7Kb+13xs!g0dGI%USeRZ`e_5Bpsz*}mBtlwIXI*L&V!2!6^V zs^LWG1H=<^Fpw;S>FJLlo|9x!(-C>46bp)XPiiRP8z(ii1FSzoMt=sC_y>YnH;Kj1 zGP})U&zM`}1RV?)Zt*{}{sMnM``w6=E(>lApgH8^BpdARr!4Th3}j2aeX0oET+Jv} zUc3WBHX#isvPBuFIrnJG?2ZM&d9$*}baz>^w(CMY{)r(fuq>gBi;gOWAS02b`on?D z)E@&6a6@&AC)zwHsf z`C*wSN^RK}jha@#A<06cU5j~Y$ChVp(?|V5ojpVc7Hh%M^Cju&2&4W7-4ca+g1z-o z|CZAlBZ^ab8zANQ8z5U#b7W12#Hz+G`a=9w8`y!5=_$)*m3hiui69t=IgiQqV(DXk z$9}Yc(J!l4Blv!sC=vUV_JJ*->z@Ct#`|5KeSu6i7N#IIEjs!mlqr0PjcK;)IOGoSFE70Z{`s$=<;_PfXg z+~BL~^<_=F#`9KIIij5vy4*gT_J>NoNKwA9N+Rgwz#bOmGmHH*q2}Wk`{$g-v7bbC z3NMeWyn?Y`(4y!UJ%Qd(KfEiPpGYa-#A&fE&6g{y5Tba6fmF3+cJvazmmJXLY9t9t z3twTEEb)7a-8~Sb(H*S!ykp1di8TrQw8N|s<<5)98Zn+2*kRgVs+h{B6&IFYOqK(emVHuyOtn4cZYaOr5|=k&>ZGOhluw1ZgTAY&R4yt2S@ zU>?{QxXkW)24f_-$nKU>|eD3Z;pXk5oDnaK0oEze?k>s{kG zG}jZ^W8tR;RjC8Ad0!Xr_1Zj#*)Dvo%l5MF^-hJqX0}eu795&^O$`29Z4Y->a`AJ1 zJ6Y1F5z6bHe)Xjzt8&=g&*>R{0ZG`$1xSn6GTU^e8u1qYDfPA@h|&3#8qF%>ik$5_ ziyLX$kY@O%*CUOAJ z>dL)^OB;DdqB-@itBbouU7yF!a?7kk)27yqwUt!{?XvPceT{D`~`u209lCa(S+RF z@rv7NFUanC#S4BPNzwN^$0|sXwcoygyH1ns%HR6~A}dGuVBB9N_TNkkeqmXOeJBK1BAq>)=@>#g(8k_}B# zS$T-!71k^@Sw%FQC_~B4Tc_tL%aujOezeZ-=yv-WlvWXBfl@)+X1#3CN3HiewvVU< zW4V{86+5@46|)Q1`|qMdr)+@nN=$Lai#g8MGp>gaM&8ACFZ@H1-Gfku+o>CTylb?< z5A{fCsXuCoFV-xP>6t$;fVR!cq9G$**82-t#Pa&MPff!&6G>3lMKAlu$dN|hA6-X< zQAb`8t!orWRfyT$FZ+EYX&gzfU+0p^t&NH9>|nOCh}(g$=p2_p(32Zn($9d@obtBy zy|$N@daX}r=#`yaDz_GQYSxIZG7A$+y~^tj7ikrr^LzhQ?dyS4>c7ac=8*dUoe=;1 z+Y;WeI>PRL#cw`_E4AsGKYNj@Qx3=;DFE5Z(8W$2@c~aUtN4Pa=uiSkbxZ-p90OTO z&W_rMCpE}At*mpGlzJ;$)3)NqPjHLEZrbP{BZIPlWEUXHjiLP;{Q+llSJ&bvJl%$x zFgUc*RWARWRu0I@#St!D0c54QD58S%eg!dsaAI_&z3o;1_Ij*U)K1Mpom#x+A1@2K zNhqhGmXm$Rm6J+1emtlr-;AB|ntzrUuoRL9MuNtG)vH4L5t4~fU58|h3eUIwvwqJG zEIF@Xp%Yvw*}EKu1_Wpy$oiMdBUcT6F7hTlDbV|E9p$*|zPQ{^t|Oe>nI*fXw7ynCkdN!I%6K%HIQ$e@Zc=;as{? zV(d?1R{aqmS1~y|<&XYaQL6-sVG-e*ZO=dXJ!RTjfUw{YAa=bgIu2yffdWU>pRn`) z$erm*xQM&YOREC9(l0{x;t4?iCMs`O$S5YvoF_?a;dKesO___=*az~2Oz z48nm;;m^WAHzOh){b_`^P(Lw;aAF4Gc(c>XYdScQe(jKLe$P6P__RO!14I~U2;+7g zZ1I#6MgmA-B!O&nM3?ym_$iFYJRKbHi}?V`yaHrfVu&YZ;g>-Qts!UDjeEJ=MDe%v za!4R2YKV}B&EO9qK>}8n!bsZDfARk!E1k{S-{Hxwjwv+=X*)3I_v%kYEg;D|$i?oC zKuo-nnk?DFH6;eSBjY;enu10DGdcgd6RA=y>N=Im>CBQ>^i;)aqdj}OKT!?>qMEm0 zH*NPj%8GAN5Ts)!ty2Q;EK{m+Aghu9QmRQHO<~Hmf5$(o8*7wO7I_Ga7Z(;X=DRx< z$+3=wcZpr{j^9Bx=Ib<13`37s94MU$WU*@<*?odt6Hod;5rz41=7!$7>H z>P3OnaK=F^q)P(n+y(|*o6?iWX3JQ`ZQOA!y0+}3|m{g3R?Z1><)Ik=l7B=XA-IAR@6+@W(N-H*m*fb z4?MDF^ynSJWZDk7x4i}+`ja@^=^l2p@B0Hpe->y^>RH#3!S*JM(I_2>a3aHr8TH5Q zjQ9N~WTwl4#YDu(h|rZu0w_A9{wPpJ1k{wsnU3knmKZ}k^&zgvaQahr#$Wv>+&LSH zxfwxB9z5ooyV2De!E!CLgLnFa#G;4lRatFm_6F8_i)(BH6wOB-{F6OS}9w zkl__R9+{k2l4uYQDm?W={}`FvGpKMdxqGm8EHw~p1b!+OzQ`frAt2eM6eDPb)DnKy z9-FKW{pZD6@6^6b-+L&_lwJ9ef0k_jz50y~S)79bbgJpc^61B?kM)L645^lYIm?O9 zak5VX>Ca$o$NFXA7lfbT1^6k3$lcDLL%7rreqtVev1srUgZ8H2m+8sntNf*agq)TN z2;iN%Ff@<;h@yvrXwlfy#DGx(ep<8)kQE9c9W7c8eqtDYS~L&RAqM#(2$%E-Z|(gC z5>@d<5kZTVfS(wHpB9bcB?b(*+c*|}e6L85;q=E(DD^r5QECV>o3BE2j{;FmrI(w{;&``CNYjU?(a{nTalbsF)&AzlU!S%SC#FNzFI5CS7& zLj7U&r_~>UpB2cg9rI6|xQHcdafB0TP}%Tx_Tf+cA#S0dakyjfsXuHm*DaF}^bSli z*~A}I9S71M1yZ*?j5X15^~W^6V5j`cuM{>Zuz6kCBz4IWo#xnhKxXG_1nhwf;)#51 zfI}r?yME?hFTJo1Z0cf|v-Hdomz}l|oU7)BB*K}UwvrYhgn>eT9DYijwv~R`T4Gd- z%_5&nh)=q3e5pW$OWO<~H}t}YptRF#JMY0+khCk03iU_jMI11YM0}Lc`4>9Oqc<2% zgUSl!)Q{<9um5xpO1VLUA~u>W`JBs^QA{b~oz>6%-%n(=2(ks6#XP1!Ua`ea9BFg{ zFLn}}nL@ZU8|jGNNGFaghMmL@bLO4HP~sQc+rIFd4asW0kYfH3<)i+vVkGcGY|Nk2 zbkW6Y-~R&lP-XW#78TyA;tHWXyr}gQ54E0Z5JR}A!?Wr~g{cO4_=yEYDlq-tbN04< z{t{8(B>0mTROU;4sc1QZCaUHI&rL?xI2je5aaG6wS?2`8iFnWSOaBUaCLmXoo#UKW z7Rjk`HM57RviJVVzd{uD0J7KB1{bW$UiD7m*{*@S+s6h6ugL^{A{90Xq}ky3;G&mz z3B`OMoC=%ObQvg)C^+JA*;WOAz09NGl15cK^bivBs#%HH!>;K`AeA%&6sZGQxhV1x zGq(MH-%8ttgYExcZDZTnw(tVMx;V=i__hC*)FXjQY;L(r$0SUb(eBD4oT^y>QcA?&qH6MqVGcds$EBYrtD+UIn|Obg;UV~` znqeUP{R4*=K>%04_~MJ~>0?0d>Er4rGM-3A5Y>PEb^~LW&d0*bvJy|7?`;M)U0Qux zH;S+v!g?&LPRC~vb~3%)@n3&r<#k{|jt>LbgbhHl+XbYi{Q_iTJ3Zz6&jA_#DvGV<1l{3P84~+0*WH;x!=F@I$-`$dVaeQJKo(^=w_dLQB{` za!X8~{S`l-ORd@+?*2(Xe$b*3HZx#F4M|}wz$;;!{;Vn(MKq(*b`*lTQUxbzPzW8! zsO(ivL2^LpXdpX&nO*v`JUzPiXWZ5-*xtYR?L>Rlu}Gv~=FIi3$ka`aNg7Bd8O0os z>5__3AX}aXQuH3QQw%qd{(^=F%iL7Li8_vOh`hg-fP*p*BZFwiIZoD8b5XPI{~Kd# z%1v1vdHy|*`fmjb*2@DsUR*v=`@`zTQwyipF+*V}=hZRpt)PgnqP)vxN!uNW5|NJ8 zs7yB(l>?GnUj11`d|IHcIY%B5OKO(T7?*`7cgzwQW0%x58|2BH$~mIJ$q=p3?(KdQx||ClU=MzP;GTImn6Q~m)v zBA+8*Kc$e4{g#K=IW)5H%ZSwQ1d!>7$k$UG?6({O*l#R=n1`QZqyS`m63COhnB8=^ z`IFT90P20V);p*Ame+a*^(??oF?ct)`V(2-5Rm#72C_a;piHEfJB(>~MAH$OkBEu1 zE^hRf)HnA?cZ3b<0pW=J5YHH!yrXV7r4lCF1K*V%)bG;$_&nfDfw_+~y%q z`U7M*s}%ULrYbS~Hp5RP#=R<^0hgSewpc5Ct-V+nRazDcJ*S@MDDXsLc$7pGg%*bheT7F z8K)=rnszo*Ww*~uTc2%5U=Pg*OgqMuq3Wn*p-3gm`(=gWP4q?;cdme<%X|*G;M77{Lh%i zBFH(7C2&n+)3JTw)ar*vUcJAL$g$;Vu9&KZ*g8$jqsYIgiE%IJhnqMNChQgjv79F6 zCcuOvA;GP7){&6sR=d){4hKISY4~lA%bJ>wO=E-Hx`K^bbet>oF1xs?X@V4MnwpoJ zaYBtB=}HOpbj=Uzl5iyeOtn*)v%zS>UlhT^tc3v+Uvt!t;WfI=bb(hA}JW`*$ETOrLX zZ)x0@an?0+{PK1Lf{=V#OVd%*yg_rEXMm%qH`IhzxVwF+rRgB8i6EYF1-rMURJ>j* zDWS82QLW5Kuv^&5@M8eG5h%IBEnKdoEkDY*_h*M|P{Lkzl*nWLQKpY1UZ61Hj_PF zX$kvnd*fc(j3D|2@S!+DYT>bc5zPzh%b_fNfrjC~Rau~1EHMWE8K-d0@w|mV zDn>D{Y&EZII++e#$!m{>CC_ra!a(xk#r!GwDIa{Cpp)qE-cD{{b(Rs_zOxEz_R+3s zo(d~z$0JIFmC>kZ5Cy#O&rVp+01p+`YA_aIdB-@8VHH+Xg@xDQz*#!4lO~CdvqvAN z!ii~E>e54nlZSuANlrKq18WLrdNr@xj#J^JH7x%t!YNbvUp zH|?T!zD1ZbyysnI%R8FpveAwrh=L6ShDW%%sK6#^R3NYn1a|LxPGBDZ4;9!Z6e273 zb(*cFz(SYVpSy@^w>e&uHfZAtuPG!Xz36zukGRb^-n0`18LLU-zaoP~l?=8VFAB2% zcu^3)tD8W&s)8I)K0$k7S%au>TUVQ)y$QRhtMFJ49-<2gO;NLHIr}Y`pt=>^%rX5a z|E#90*|HqEe@=hb{qF-0>3%!4o3=L8&Q&G*E3M5{wRTfC?eV_I(o1?==7%`EyqefH zT|^1mcUK8yly@M3v>lHq86rD1Dv&@LJ^tHwT#s)B9@^upyK6UgI@Z;;rdYho?3dl8 z$IBy{bh;+3DHkL?Ga@~HWkhALS(Ao-MFz1d8LW>;kME91Z~cU_&=swF826oyN$p+j zg7!wn+4Fjswz4EiX;4ZJi+n&7V+Zv%3$Ns6BY{eMjtCYWd;lmDC&&4JWdOQ6OuGZ-?}i_2HX+WwBqcpK+faig(g}I>+AK&zvp~R?pTj z9;oo*feNe10~M*RJ=o6-6N8`8q`Wc4D`m&@H^+#L-KkMY&BZE8kHXK!rGXUYGP}4x zgo1A@^*4Cgrk~q#4E@51=2ZBZt7>7z26z5n`=-$YWWY`tfQ?HU+S(VncwO_Q>}zI)(!R18wfH&#>Bw0s3SW{_CE8AvI!-EvJ?)AAKy`ASZ9mhU_um8fbZXt#rl z%vA?8J08G_3gh-sXhd{qb^qM)uoLp72s^ZYrbbOGd2nL*@wl}D{uFfFE{ej`;pBjf zVyd9jjT==-Z>XuHw#{JEN(Sr#Fd~zz+Sdf`C$P*UkPS!yISTkBB{2=;fW?cLgUv|U zA7;TK*c0${hZml9{CKeGDNCe1T13r(7Jb-u8)Ak?dqQ0sRevtT&K!dM0=6?l%t$%w zizD_p)J7_Lo+}{-WJQC`SRQ^>uuv4wiqbE(4@d*d^m5^#hX$B@X!}C&GlqP)m{2~I zl7J^ee3VMgCXs+ALp&+sMFxB(hkl+>k^!GKR0e{)fy8utCWrBS+K>-g@M5}_K*q~+ zv_L*>=;BXu3HY=j6Uftsz;IOp7vEOn+W}d?u@b+w|EcCaZvD5MhW+hiTW=`z1P53{ zjk`hTb%WF)#A3PfOptPoqIy&Sa-&>hT3yCduJjknmFam=zLYD|QLe0Yv0Rl;r6izS znXy=|@uGq#SNexY>qDc12L#2n2UA>(+(2$-YEcFPDk96dNf_PkwB*J0nHyACuv9zn=F^K|3x5l+&W zoL%E$b`d7qpO66Q+K)6HWgIn#I1Z?iToIf@Nly*3SB=E6Lcz`-X-+Bcj9_+H)r@5K zj>JjiWIO5%^SoG~vC1fIOGfEYW)eYlRTCmu6;LCheeBRt=0xPYb(9$-Lp_INUxPQJ zgJj_o?E5a6f2Pdcqs}xP2j)?WV1_%byE`PS7j+e`su||Wa1YFtwZ6T%`b;tK+rUYl z%3Od{jkS9G9ssY5B0fiPmN^B28g~}XttQ)9XPH5Alo9G#UD=qnDP})9%ea>g`yx7M za!sCT+b@RY2)?Wgke1Iz>QA)g0kh}{_LGqta%}sJV)u~tY@l>FvIp`<#z|-TXFpyUk z;q0iaU4OQ@S{_Y`A$wbuBOle{l>jn(3djMUR)5@%ImftnAg3c+ApZYTwxnHuj=5Nj zdK%d#f+H)Dvz^a1m&gIaW&|@RdWL=ET-h*fJlAv_9LK?BpfP7Hlm>ZI5k^d7X8qSrJs)}ncI(GtaN{}Fu~3xD zurfNNm9~7GEQZI8gZ@X9?|+o%xixv(#p7hpy9TM-XNuJ75?!plS4*wxTp)_r`T}Xw z;0wgiTzP@9s9BxgpwPUs`Gc}Jyp~u-AQ@6m{f)4g?|d77BZsW*FEs9xd!atAZz5;g zi!L<%Wn~%Z!btMN|7u4YLi_ArGuo2U^ss%}~1F*XB=KeXEi$4zs{ zbX**wP1>38l|NA{(TceA-J3|%J~6O*%>4H#$9@6A4rN1E`G^kgta9?D)C1(xplo<-iEDTe?japl1;u`@wgNwqs{*DIe%HHI0pEUO z1sIPzs|~&2ansn^-4h`~_gp3_RQ1#!67tI5=@{h{vq1Jl4%UmvEiWsSx2IiduI<57 z-YC2k9qN1L>m%~@5{JhRi`9+D^P2q|RsCZ^PH(&??WdQTQ>63=qL&~mt4;mn#c#{d zPZV+7rFx5a>H*WqBFSEPnYmsT+GD|nS%PN?;@I%BoA@9ZSww-NN(dKK0+Izjfq%KV zM@CK(EN(^+*#u8N#*__bFtWp21ecqurJI){`BgQNr|sw~WR|@33M^spLGVfD$9#f11sVHF}8y@Lvmt8B*%j~<>3=$oljqJe+l1~%zibB3@DL=<0XvZYX zNOyjNl0#NcPC7DG4$R*m%_s&%8NJIrkW0J5(wh7S)mi`#HnunpK1n_ zQ17QBNke<Qa#c}lu9IEX{_D(Ha=_f; zWLLEq_RPHEz0?9py=+Nyt`Z?w|y`!Y4Wvioq`uKGD8@y*Uq)Ma2!q z{b9s9Ee!`2H^}5O;|5u(EWAPaMg~={9iuh)(yx3!zrhS`nqFJ#y$ot_qwn7TpdTN^ zHWxSNG2d9QVz)@$@PiRxE3fdI+T1jA6sE3y(@eYa$8qXFEvQ<&*)}&yjRxOnI@XO2 zu~*$_j_y2Ri0gDorBAjc!M;L1f6x%0KPYjZKd`Hjt#edQ&X(?TD)<_Z*G08X&+6PH zg|)c}N6nKL;;1=xlWEbs4UW&KW5vbg1~sy0H8o8|XAab8TK?3kPyZi@mHm#JOuIH8 z;LMHEs%jZT!hF?tA1kIy;r*s#CePam(=n6(Tf!4!za1Z5j)9sl}iCw71?WeYNmb+0}2m zRgBO<2kmb|o$%+7x1km|bG=QEU-3`xw@G^!BfdO*k~_Cugg>I-&^cccqVoH06V4@- zNL0{711nsjj&|`e%574>Z7LIHG~xK>F5zd@#ioLUyO=P7)!=Z5wRJ0(@CxB5$FA#6 zIdmhHho4v9SExfcy9_p2c63tOab;4(JU1!Ivd(Sq5WOhg^zrxwB{vK{i&6YEam9zD+gMuc(vq}h(m3WKc)kjSc ztu>x|nj^eN%+d3zNk|neje=BpyV#}Lg;X#W6?aQ32Hq_qxy-@byT!<_yjyP$Bu7*; z!hg3h5kBj9k8z*7$|3d>Z8-k<@*Z=K2rPWM6Igrr|8|bV^p5|)!xz+c zBUk6J&LxXaapvkzKwg7t%_TkNicK0Z7iDGbRQSsOUb8H_aISgUeb)p+ zo?^20s`a}2%;}KX#{0x59lQ@^;J(`ZvU3`BKaL+J+j;lP5zVgqjr+f=og=X>#t0DA z#cGO}0kZyC#T<}>BoE{U3gS};osvdov+U|N6tjs zRBOCSS6UvqD3TET!u_|dZLA3|d=T+rsq@~4MQh5f>D7>eS`KE%)v+JHWR5Q9hKT|R zF5LdM=3TLFIV5AZ2G%VLt0v`DTelk*$b-MD7pO*r&vIFUeJGv|Tp-Jv(-vYu6g#E* zan*M&AuHT-Mi^aHt6)2Cp;Kq%=n=y5mTQJ5uW_k+)V#%fJRV0tnbiw~9*^kbCt0lu z3*nXT>sHUnNAR2_Zcrdyb5m3Qzz+HBYvbiFH$Htt>fZZNo@N%+{lP0;ZQiR@_nD6x z_kWyyV@?pUk*=>2KxRq;*;gsM@loUcRVS`V!z1m_k7CuwUCnF_%V_qT?fsba;rPd7 zWX*g`R%(k7*tGy#6Bx#m)L2baV^vnmSnYpIS8<)}VmR$li*TDDX1gvjt=eB(Gc{_` zX*4Y4k%t#q$0>_YXsW2tQXaE>`*5}lR;pIGPUi{`%A?Rl`j(^ z3|=N3Hh!5KD-M>rxE;%6h4Sq(sY08lQQw3e@w7Rn=e!{`ED^MD()qA0qWulr+5llG zgh_iw+DD!?XF{*{f=iDuoC`~M;z`(LkA#}_eMNJs@Eukm!m@|r93zWAA!i& zsmtZ;W+gykBV(&$bNDQ>#P(hx>w&2&uw9ItVjo$7^NYbX%@Hb_MCS4q^}QCCHpUPx z_Zqx&I2Sl8L{8<;$VzUsgL%)$*x2!m^t=D8=_CEVV4Q1iwCZqa9HOLVSwz)Eo7gQ4V%Y33Dh_1J6Y7sDVqO27Imap21+I{IRUubCr-k%IRIt6u+SSi#A#u$S z6q3?H(#kwpRY>_tbCw*G&PJAC4vk^1-GH>q!9#rf95wy;+pCr4YB>zcAp6}2 zVw1dET=sCVlL9h%=oZ^;mAOC`dOMLmSm>o{EcDW~7kV33$?HvZo|j%Y?Rgo5Q=i9O zAgulvh=p?o-8R|w$e2EI`?ByGC$-Q>dt*kH{tGjr`D-$w`Ma1N4@$0vwM**!)$4LL z*Yg(FR2DmWwP_`CT6ZC=gm!EE4A+MV#UxPXFZFYeMuFgE%+(HL~Y4d0w*@ zYGps|1vWG^wQo=x%9#MaF>7NGKBgpgZ5&lemuP3 zmABj1iupgdR^OwGBgM^Hp#%!x6;vpx6-xa|h0;NI#8oK#YZcOpk$3A7_g8-pmK5zI_;_JvnW2bsnjOsv+}=qUU7E2f)hKm_6B6V*IeM{YFjB+FPt zeWJxg&a@LZYL=vC$!nH^U5YHw@GTpS`@^_g!1P4h_ElLgjCoZT8sSS^c`>{2Rd@P{ z7?DE)F~Jmy$8cUl>|{IQH9UkwDP@ad^LCMo-Ts=4tnw`0Ipio{LwGVq45_kF>FBSv z6r;1|COL}gdujEtAmznm0UI6@(q90|V5`NZWRtlT8aQKtC z{;XnJM|V#BJl|)!YIC#g4S7U&#G7hUCZW`uwbVRv@d`>Uz|T^>ng6@gkcMZq)Uf(% zm0Dy|mNj!WWnA+k?2m7nX(GfFD)BYi$)@Mku4HcwggEbyD(iJfR`sPh+=Atm*JK5| z|BvoHz(1*BX>djLD9h=PSfG0L!5&6k_!NyYek_t*}XeOQ%V)A+fW?(fLfK?$ zv8i}l?#Il1TV~2P-xh;#@NF3q{kF+)nX%0bs890{ztVoW2XCL?e^j@bF0vb&eWg?X z$Qajai7NGP{b$oh)IWnH{Z$dUYvC0rVg!msMU2)^#JKtsib)`6+m!laiq-1B@z0|E z{r+MGNU7dc)vu=KYLptj-IW>vauD$%2Bk(dJO-4<7y^H-Qa}2OxuZWt(+S1ht({QN z$<7<@6d|M-2C^95wdRf-_u3!Hne(I*W5~auM*fuMODLu_e@-z2^-%KfqoWG0Ai3-U#r!8+PfJ1lkG?E%Geq6o{pV$ zMa)p_AxfWDCIwac7z_{VTFta%hmM^**!rQ7;_*=~$_UzVP8 z-j_kM1t7E2^2x4C0!e$293@>eJ9Yc37?&&mDmhm;*!x#m!gk#$1M{+-GB6kJl!3Wx zr)+MU?$TK?bd4(`dV#%Qm#mI%-DP@7@o|j`icg(btN8bKxkG^uL`6>fK-Q*HJ}~Et zrg+yLTK}9f$YTZy+M$~A^#^9C@l7Hx#iO{$_eQwbnQ; zx_K3BoexbH(W*3}YHC$ddydAFT2Ns7cQb`MrMz(;!;VaC$I0?BRg}%sY@r_Z^UtwCn`~QuAqHdq7h*6<{w)n0 z;b4w~Ee=ZdneK9$mbhIYbWkAcgtDV0i>tQBg5?v`vO(J zsagS&SXT2&*}-4x2PB3gI_RpXM#t>DFHLv%LQB8XlFtvhm>Lx$oMpnOP@q7#ToyVxmJ`t3wFW43)mIw9UEL*@9Guw`HeluKCwn^ zqr>g(1#`00HZ;BZQR#qv9LHp|R!PN#9-F0rtZiET)e2j(-&`-+(--Ul*@=2Lv9S-$ z;r~O&*X)<^)aGk5sDxshjhy&*i$9oS?Ch`2W1Sh6LD=a*<@M+^p*kLipKVXrQU8&N zdggyjcgf|=z)nwd0Yp%52^&gp#%D*v#WW^dot`9UWiwb$g0#N4Z>J{tV z-&v_v1Psa3kScHHe!L9hlLWorPf0xk6E{ zu;z;Nuv@=1-K6jqH$$UU=Q8LWDO@pYE56eMr6`iI;H28rl%4XO-bhZJhpqr-ot z9uxj6bHu{`!u(FQyZ$TlOPim>7*BAp*uicGZGLuf4i*E%G`Fb4GD=5KxW>4P=(vmC zVjKQqP88D|MSAvm^&NX;^W-tdC`T!2;ky7@ns4UsI#8d=gRxJ;1%#R&Aa*VHLHSt_-> zs8WR*mGbnK5WlNfHUNvc^15Zi0ORVG@l1YE-LhUHzTq>gx6C1(**KB&3M-EgX0f_u zCn8&q!^)hsTYzjgtJITPbdW6-$ivP-@=88N9|yMzJivMQ5^Njbj|TqRou9WNEASqU z|GM+X=&z{j9Z{#bq)*e|td4iYCoVo7_%;1l`j4uMJ6J{OS>Efg)&<+|@Um7?k2KS{ zaN#TYnjHqdhb8Jpig*jJj*=#fX1cl*`McEjj=D62o0Mf<{TJ#cIBj(Ls`}G|nKe#= z@vGFpTvd9xXhg6{=+oFmTn*%PJCGJ=Gmzb!*Z8U@qp_)op@4rkap`M)UJmio5kK1Y zt5?>lE9;YncL`Wg5!0woX}K$M0Fc)P^?zb#B8$9rvKd(d^ONf7lJR8>)+~qT@9F~i zQU+_714|;Wr;c>2Uj_2|3y^vK31prhflS-1shi4+TQ$4T$h*90scluitfK!LXa+NW z+YDQS=B3_;D#!8`^}H4nkIXktl*dEpry$)GNHZVQ;3a!a{j!$5gYY*H_66vKmmHSh zw)Wv|tMh%;vedh|RjD@(@i*Iz^~(mx_mHxv%RF1>h_XR#$ca_I@YquCEzn$~PB@~h`KTaS0zvnIp3-Ei;9g(kFKJodYYAm(d0n&ok%=bpprZScW>*KC`6J)1 zwQn9#c39&&5!<6>SrhwVMcKJ^QfuMtML{u89F$nw^_H@=y(P8NSTdlX7KfcS%`_zwvC5eUkI;{OGL;yHz8p>ICWx zIu6tYbUdgls2iv|C<5vM>Iv!vIsw!hbRwt^s4u7=s6S``=p@iU(8-`dpeSfCXb9*O z(5ax)Ktn;pK*K>JK&OL7g3bVq0-Xsu3lsy52AvH$2XroI4CuVI2VXe<>h~)5V*Kp6 z(DyiwhC-G{jrT>cTi`x?tnX32CqkxlQ^pfPXTVKazYRK;X@K1z^A>PZ_6tBvp9XFK zF+IoRFpkM4WmRJn?n)V?XJC+eMOh9K5?gVYoSnM`Hk!5eM^Y zAm-(u?tuZ#a1QP#Kn&*~Hyq>$=V1Rz-5m5+eSq=92#)?8Ai8M3}T-Lwj~ zz$&m3v<}~bSUFmWu^^_Wwb-t1T8);l7!jnQ^|%YfG_)f9|L*uiE8%zv#0Z*|%VAc? zl;-7Ab;HbfQ(<8kPHXcSEe_Jt>a2j(VTEXYz5!(rPAha7C=WNS(Ho#F+_Xx~VU2Qd z(>l!tRgw|S)K?&ii{|Pgm@0PrXB1B8_00l!za~EyV#R<5sYXb8^bnA!GWDb zIPGOQY^8()Uj;Ee?dLGsPq=AEkNOIx?8r*g8jfioW}spHLftg5XB1p(V!&tB9oX9o z$!LE=?|BgO()O0Y?s^EP4SpEJaN6Q-u)}oICO@uj+GbM;!^{X8={waO7;6cK!Cpn9 z4W=~S&A)T$X~eHrH;wszbdpZtVs?=V0gu4!!isXXxx%)&cWm>@GE6j#=A`q0x;gpu$D}hENjV8U z3t}2hM(=_$<9wNvPQrx5a86FosympZ7|zLR2}tq+2V>Hro0C@=CM{G2U!;NKS`Zt~ z+3XJ>CgQAi9A_~y0-gyv35Jj}+-)FMf-~I;b#umBiy4o3IrIG$GaucY0motX3xPjp z!q-8PogXS=Nsg;faZ5c zKY_+RX1pXY4ay&5ybN}9dqC04QK4Rt6exWeh9&S$y3v?S-%{^A(7LybR{$Q_4_)$% z@%S0P(?OAu#^V>$ZtjQvZ-Kr~!t)fU#hoYs_#SA|66588hxdo3&Nd!j!#x2sX^!#u zKJulY14z$jI35DE_`rDl!rvR93<}^w%BO<*u0vtK8$b=NG+qwa>?AZMg)}E&{Ew&S zKI6rK%RtlN=9`}TK$-i|0$|sHXer!$v2zNj1v2w9AdiC@Ohv)Kw?L5>Pyn#m$ru&g zQ2_9A(7M$~4}1v}hZM8GFF?~PC!hd0x(`AneuJ6<=Yp2EH(m<(F2li;Uv?^u;)(2w zAPL}kpu}8M8aNA-MK|$9g*BjcD^UROQ&4IV_yd~`Mz=kO3IR_8bwYRySUC+pF=R*r zmx9s>v>vzvwC8RV46HW3V(w>Ofma0vJ235|QWveyH(g&~y}# z1U>`W2{%9N^8x4p6o+5uX*d*`e>Exr><`LahDrdh0!@lR62M15JD*1-fLlSGP&vLI z{R1d{FC;Mx6B}sK6_7+F92bBVpny2=CXffF86c1HIh?{HOTB?0y7`jL6m_S7d}~IA z9B`Yu^T40f?VSOe4`P}qaJ;(1XO()RK$TKJ3?2~Od0_p~&K(90Rd)<{qq_OJ=}L9y zfIq97pKgB~L_S&IUUi4g!3Y8|JOUi0?({jOExnEE$pQDN+dCJvwwoRr+oUoD=SmRs zW`Nt&%|GGTr*8gnOIHxnM1kYg9S7d3?j&%Ly3@do>Mj7Aou_p^ztsB@B=sCy>RmJz z_KBV}Jbwn!&F@?u2a>{oSE)M-+^24SEWIm;X+m+B1(5K$1bPXgy8sMbiXNPeG{6XG z`jZe4Fb0}^A7+9}p`RPzVFrE!eH4i9#1*C9!ysV_{G+;azgFG_uUB{Ux>D~N5b{=fDLB?U zkC(h2Qv`@i`FBg()Xk6MoD5<(KZr9&-TVg5tLo-=Z%S@-Y52L*f$EL|uTnR^mOoeB z{9^vb8`1w{8odcE1WCcboVvr)VUIvkFfgfZZ$_!t4aD#~@F#WWZpYF9#PG-+u)?6w z248T#y3@cdAjw+^$M>Mj%f4Wj-#K>#7y~g;lBjNCR^7zBy1iMYUI!4<^Fzunshb~D z{!ZQAok#?f+JH_#CX< zKn%|U_o|!!DBR|5@MI)K|jOv692?hK+k0{qAQ=<s8vc3a$?E1GVqT-}6mY$|`EQ(i)m>SDgHcu-llH;+vF;B^Y1@*sXGs>|A^ZE z&?9IuXfqD?l8<7s14^cS!B5rgJ%;`RQ4%rWW>ACIe8GzsId>A6CLv7@_!%g++!t)~ zxN}Er3Zr9UI`_KQc-K!A=;L%H5qA>7Gb@S77Gt`|0?pHVe@%K!S z6aeJk17>_9$iD@A18!nsxeF)01$qzRsTCOi>+!?P{KwcYL3Br+E%h!2(arzB9So8J zfHTyc0q#D{9o;<>P`c>EOLFMi6V!Z delta 102519 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 *Se&+ey$67@Jm_y**9-A6__l_Z!+$mA~tGn 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 diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll index 0f2afec245f7f73ec3a8d0f72b5892653f5d51cd..b573476a21d7fd73ea1e4b4a53794377954fd845 100644 GIT binary patch delta 108350 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ú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∓^R(ytjX5wL>pP1 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 Date: Wed, 13 Sep 2023 10:46:48 +0300 Subject: [PATCH 380/429] use more decriptive variable name --- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index dd61e68f3b..7993b3352f 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -21,11 +21,11 @@ class ExtractFBX(publish.Extractor): # get rop node ropnode = hou.node(instance.data.get("instance_node")) - output_node = ropnode.evalParm("sopoutput") + output_file = ropnode.evalParm("sopoutput") # get staging_dir and file_name - staging_dir = os.path.normpath(os.path.dirname(output_node)) - file_name = os.path.basename(output_node) + staging_dir = os.path.normpath(os.path.dirname(output_file)) + file_name = os.path.basename(output_file) # render rop self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) From e5bdb9e98d5c1291c4db2b573f26c34896100b2d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 18:16:35 +0800 Subject: [PATCH 381/429] unparenting all the objects to the containers & uses the OP data to get previous objects --- openpype/hosts/max/api/pipeline.py | 23 ++++++-- .../hosts/max/plugins/load/load_camera_fbx.py | 38 +++++------- .../hosts/max/plugins/load/load_max_scene.py | 40 ++++++------- openpype/hosts/max/plugins/load/load_model.py | 20 +++---- .../hosts/max/plugins/load/load_model_fbx.py | 59 ++++++++----------- .../hosts/max/plugins/load/load_model_obj.py | 32 +++++----- .../hosts/max/plugins/load/load_model_usd.py | 55 ++++++++--------- .../hosts/max/plugins/load/load_pointcache.py | 21 +++---- .../hosts/max/plugins/load/load_pointcloud.py | 20 +++---- .../max/plugins/load/load_redshift_proxy.py | 24 +++----- 10 files changed, 148 insertions(+), 184 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 72163f5ecf..6c40acc56b 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -164,12 +164,11 @@ def containerise(name: str, nodes: list, context, "loader": loader, "representation": context["representation"]["_id"], } - container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) - for node in nodes: - node.Parent = container - + #for node in nodes: + # node.Parent = container + import_custom_attribute_data(container, nodes) if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container @@ -223,3 +222,19 @@ def update_custom_attribute_data(container: str, selections: list): if container.modifiers[0].name == "OP Data": rt.deleteModifier(container, container.modifiers[0]) import_custom_attribute_data(container, selections) + +def get_previous_loaded_object(container: str): + """Get previous loaded_object through the OP data + + Args: + container (str): the container which stores the OP data + + Returns: + node_list(list): list of nodes which are previously loaded + """ + node_list = [] + sel_list = rt.getProperty(container.modifiers[0].openPypeData, "sel_list") + for obj in rt.Objects: + if str(obj) in sel_list: + node_list.append(obj) + return node_list diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index f040115417..39bbf79d0f 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -8,7 +8,7 @@ from openpype.hosts.max.api.lib import ( ) from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -22,7 +22,6 @@ class FbxLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -42,17 +41,13 @@ class FbxLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.container( - name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) for selection in selections: - selection.Parent = container selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, + name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -61,12 +56,13 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_fbx_obj in rt.selection: + namespace, _ = get_namespace(node_name) + + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_fbx_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_fbx_objects) + for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) @@ -78,20 +74,14 @@ class FbxLoader(load.LoaderPlugin): rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() + update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: - if fbx_object.Parent != inst_container: - fbx_object.Parent = inst_container - fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.name = f"{namespace}:{fbx_object.name}" + if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] + f"{fbx_object.name}.transform"] or fbx_object.pos fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] - - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == sub_node_name: - update_custom_attribute_data( - children, current_fbx_objects) + f"{fbx_object.name}.scale"] or fbx_object.scale with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 98e9be96e1..86b7637c90 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -7,7 +7,7 @@ from openpype.hosts.max.api.lib import ( object_transform_set ) from openpype.hosts.max.api.pipeline import ( - containerise, import_custom_attribute_data, + containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -24,7 +24,6 @@ class MaxSceneLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -37,18 +36,14 @@ class MaxSceneLoader(load.LoaderPlugin): max_object_names = [obj.name for obj in max_objects] # implement the OP/AYON custom attributes before load max_container = [] - namespace = unique_namespace( name + "_", suffix="_", ) - container_name = f"{namespace}:{name}_{self.postfix}" - container = rt.Container(name=container_name) - import_custom_attribute_data(container, max_objects) - max_container.append(container) - max_container.extend(max_objects) for max_obj, obj_name in zip(max_objects, max_object_names): max_obj.name = f"{namespace}:{obj_name}" + max_container.append(rt.getNodeByName(max_obj.name)) + return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -60,32 +55,31 @@ class MaxSceneLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_container_name = f"{namespace}:{name}_{self.postfix}" + namespace, _ = get_namespace(node_name) # delete the old container with attribute # delete old duplicate - rt.Select(node.Children) - transform_data = object_transform_set(node.Children) - for prev_max_obj in rt.GetCurrentSelection(): - if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa + # use the modifier OP data to delete the data + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_max_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_max_objects) + for prev_max_obj in prev_max_objects: + if rt.isValidNode(prev_max_obj): # noqa rt.Delete(prev_max_obj) rt.MergeMaxFile(path, rt.Name("deleteOldDups")) current_max_objects = rt.getLastMergedNodes() current_max_object_names = [obj.name for obj in current_max_objects] - sub_container = rt.getNodeByName(sub_container_name) - update_custom_attribute_data(sub_container, current_max_objects) - for max_object in current_max_objects: - max_object.Parent = node + update_custom_attribute_data(node, current_max_objects) for max_obj, obj_name in zip(current_max_objects, current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" - max_obj.pos = transform_data[ - f"{max_obj.name}.transform"] - max_obj.scale = transform_data[ - f"{max_obj.name}.scale"] - + if max_obj in node_list: + max_obj.pos = transform_data[ + f"{max_obj.name}.transform"] or max_obj.pos + max_obj.scale = transform_data[ + f"{max_obj.name}.scale"] or max_obj.scale lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index c5a73b4327..5acb57b923 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -2,8 +2,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + get_previous_loaded_object ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( @@ -20,7 +19,6 @@ class ModelAbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -52,21 +50,22 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - import_custom_attribute_data( - abc_container, abc_container.Children) namespace = unique_namespace( name + "_", suffix="_", ) + abc_objects = [] for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" + abc_objects.append(abc_object) # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}_{self.postfix}" + abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name + abc_objects.append(abc_container) return containerise( - name, [abc_container], context, + name, abc_objects, context, namespace, loader=self.__class__.__name__ ) @@ -75,20 +74,19 @@ class ModelAbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - + node_list = [n for n in get_previous_loaded_object(node) + if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): - rt.Select(node.Children) + rt.Select(node_list) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in abc.Children: abc_con.source = path rt.Select(abc_con.Children) for abc_obj in abc_con.Children: abc_obj.source = path - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 56c8768675..a8f6ea90d6 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_custom_attribute_data, + containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.hosts.max.api import lib @@ -21,79 +21,68 @@ class FbxModelLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - - filepath = os.path.normpath(self.filepath_from_context(context)) + filepath = self.filepath_from_context(context) + filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) + rt.importFile( + filepath, rt.name("noPrompt"), using=rt.FBXIMP) namespace = unique_namespace( name + "_", suffix="_", ) - container = rt.container( - name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) for selection in selections: - selection.Parent = container selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, - namespace, loader=self.__class__.__name__ - ) + name, selections, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt + path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_fbx_obj in rt.selection: + namespace, _ = get_namespace(node_name) + + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_fbx_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_fbx_objects) + for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) - rt.FBXImporterSetParam("Mode", rt.Name("merge")) - rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() + update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: - if fbx_object.Parent != inst_container: - fbx_object.Parent = inst_container - fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.name = f"{namespace}:{fbx_object.name}" + if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] + f"{fbx_object.name}.transform"] or fbx_object.pos fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] - - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == sub_node_name: - update_custom_attribute_data( - children, current_fbx_objects) + f"{fbx_object.name}.scale"] or fbx_object.scale with maintained_selection(): rt.Select(node) - lib.imprint( - node_name, - {"representation": str(representation["_id"])}, - ) + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 314889e6ec..421bd34e62 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -10,7 +10,7 @@ from openpype.hosts.max.api.lib import ( from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -24,7 +24,6 @@ class ObjLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -39,15 +38,12 @@ class ObjLoader(load.LoaderPlugin): suffix="_", ) # create "missing" container for obj import - container = rt.Container(name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) # get current selection for selection in selections: - selection.Parent = container selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, + name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -56,26 +52,26 @@ class ObjLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_obj in rt.selection: + namespace, _ = get_namespace(node_name) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + previous_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(previous_objects) + for prev_obj in previous_objects: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection selections = rt.GetCurrentSelection() - update_custom_attribute_data(inst_container, selections) for selection in selections: - selection.Parent = inst_container selection.name = f"{namespace}:{selection.name}" - selection.pos = transform_data[ - f"{selection.name}.transform"] - selection.scale = transform_data[ - f"{selection.name}.scale"] + if selection in node_list: + selection.pos = transform_data[ + f"{selection.name}.transform"] or selection.pos + selection.scale = transform_data[ + f"{selection.name}.scale"] or selection.scale + update_custom_attribute_data(node, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index f35d8e6327..09566c2e78 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,5 +1,7 @@ import os +from pymxs import runtime as rt + from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, @@ -9,7 +11,8 @@ from openpype.hosts.max.api.lib import ( from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data + get_previous_loaded_object, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -23,16 +26,13 @@ class ModelUSDLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - # asset_filepath filepath = os.path.normpath(self.filepath_from_context(context)) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(filepath) - filename, ext = os.path.splitext(base_filename) + _, ext = os.path.splitext(base_filename) log_filepath = filepath.replace(ext, "txt") rt.LogPath = log_filepath @@ -44,35 +44,32 @@ class ModelUSDLoader(load.LoaderPlugin): suffix="_", ) asset = rt.GetNodeByName(name) - import_custom_attribute_data(asset, asset.Children) + usd_objects = [] + for usd_asset in asset.Children: usd_asset.name = f"{namespace}:{usd_asset.name}" + usd_objects.append(usd_asset) - asset_name = f"{namespace}:{name}_{self.postfix}" + asset_name = f"{namespace}:{name}" asset.name = asset_name # need to get the correct container after renamed asset = rt.GetNodeByName(asset_name) - + usd_objects.append(asset) return containerise( - name, [asset], context, + name, usd_objects, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): - from pymxs import runtime as rt - path = get_representation_path(representation) node_name = container["instance_node"] node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - transform_data = None - for n in node.Children: - rt.Select(n.Children) - transform_data = object_transform_set(n.Children) - for prev_usd_asset in rt.selection: - if rt.isValidNode(prev_usd_asset): - rt.Delete(prev_usd_asset) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_objects) + for n in prev_objects: rt.Delete(n) import_options = rt.USDImporter.CreateOptions() @@ -86,17 +83,19 @@ class ModelUSDLoader(load.LoaderPlugin): path, importOptions=import_options) asset = rt.GetNodeByName(name) - asset.Parent = node - import_custom_attribute_data(asset, asset.Children) + usd_objects = [] for children in asset.Children: children.name = f"{namespace}:{children.name}" - children.pos = transform_data[ - f"{children.name}.transform"] - children.scale = transform_data[ - f"{children.name}.scale"] - - asset.name = sub_node_name + usd_objects.append(children) + if children in node_list: + children.pos = transform_data[ + f"{children.name}.transform"] or children.pos + children.scale = transform_data[ + f"{children.name}.scale"] or children.scale + asset.name = f"{namespace}:{asset.name}" + usd_objects.append(asset) + update_custom_attribute_data(node, usd_objects) with maintained_selection(): rt.Select(node) @@ -108,7 +107,5 @@ class ModelUSDLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 070dea88d4..995e56ca37 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -10,8 +10,7 @@ from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + get_previous_loaded_object ) @@ -24,7 +23,6 @@ class AbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -55,8 +53,6 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - import_custom_attribute_data( - abc_container, abc_container.Children) for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -65,15 +61,17 @@ class AbcLoader(load.LoaderPlugin): name + "_", suffix="_", ) - + abc_objects = [] for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" + abc_objects.append(abc_object) # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}_{self.postfix}" + abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name + abc_objects.append(abc_container) return containerise( - name, [abc_container], context, + name, abc_objects, context, namespace, loader=self.__class__.__name__ ) @@ -82,20 +80,19 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - + abc_container = [n for n in get_previous_loaded_object(node) + if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): - rt.Select(node.Children) + rt.Select(abc_container) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in abc.Children: abc_con.source = path rt.Select(abc_con.Children) for abc_obj in abc_con.Children: abc_obj.source = path - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index c4c4cfbc6c..2309d3aebf 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -2,11 +2,11 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace ) from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -34,14 +34,10 @@ class PointCloudLoader(load.LoaderPlugin): name + "_", suffix="_", ) - prt_container = rt.Container( - name=f"{namespace}:{name}_{self.postfix}") - import_custom_attribute_data(prt_container, [obj]) - obj.Parent = prt_container obj.name = f"{namespace}:{obj.name}" return containerise( - name, [prt_container], context, + name, [obj], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -50,14 +46,12 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) + node_list = get_previous_loaded_object(node) update_custom_attribute_data( - inst_container, inst_container.Children) + node, node_list) with maintained_selection(): - rt.Select(node.Children) - for prt in inst_container.Children: + rt.Select(node_list) + for prt in rt.Selection: prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index f7dd95962b..36bc7ac9e2 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -7,12 +7,12 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + update_custom_attribute_data, + get_previous_loaded_object ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace ) @@ -25,7 +25,6 @@ class RedshiftProxyLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -42,27 +41,22 @@ class RedshiftProxyLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.Container( - name=f"{namespace}:{name}_{self.postfix}") - rs_proxy.Parent = container rs_proxy.name = f"{namespace}:{rs_proxy.name}" - import_custom_attribute_data(container, [rs_proxy]) return containerise( - name, [container], context, + name, [rs_proxy], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - + node = rt.getNodeByName(container["instance_node"]) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) update_custom_attribute_data( - inst_container, inst_container.Children) - for proxy in inst_container.Children: + node, rt.Selection) + for proxy in rt.Selection: proxy.file = path lib.imprint(container["instance_node"], { From 78ec30cb392bd3fbe686101329089d63da797e86 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 18:21:17 +0800 Subject: [PATCH 382/429] hound --- openpype/hosts/max/api/pipeline.py | 4 ++-- openpype/hosts/max/plugins/load/load_camera_fbx.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 6c40acc56b..86a0a99ca9 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -166,8 +166,6 @@ def containerise(name: str, nodes: list, context, } container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) - #for node in nodes: - # node.Parent = container import_custom_attribute_data(container, nodes) if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") @@ -211,6 +209,7 @@ def import_custom_attribute_data(container: str, selections: list): container.modifiers[0].openPypeData, "sel_list", sel_list) + def update_custom_attribute_data(container: str, selections: list): """Updating the Openpype/AYON custom parameter built by the creator @@ -223,6 +222,7 @@ def update_custom_attribute_data(container: str, selections: list): rt.deleteModifier(container, container.modifiers[0]) import_custom_attribute_data(container, selections) + def get_previous_loaded_object(container: str): """Get previous loaded_object through the OP data diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 39bbf79d0f..d28364d5c2 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -81,7 +81,7 @@ class FbxLoader(load.LoaderPlugin): fbx_object.pos = transform_data[ f"{fbx_object.name}.transform"] or fbx_object.pos fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or fbx_object.scale + f"{fbx_object.name}.scale"] or fbx_object.scale with maintained_selection(): rt.Select(node) From bdaeb3c92f3ba5c2744ce69fa3aadd3135f33699 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 19:08:09 +0800 Subject: [PATCH 383/429] add loadError for loaders which uses external plugins --- openpype/hosts/max/api/lib.py | 16 ++++++++++++++++ .../hosts/max/plugins/load/load_model_usd.py | 8 ++++++-- .../hosts/max/plugins/load/load_pointcloud.py | 4 ++-- .../max/plugins/load/load_redshift_proxy.py | 8 ++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8287341456..4a150067e1 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -407,3 +407,19 @@ def object_transform_set(container_children): name = f"{node.name}.scale" transform_set[name] = node.scale return transform_set + + +def get_plugins() -> list: + """Get all loaded plugins in 3dsMax + + Returns: + plugin_info_list: a list of loaded plugins + """ + manager = rt.PluginManager + count = manager.pluginDllCount + plugin_info_list = [] + for p in range(1, count + 1): + plugin_info = manager.pluginDllName(p) + plugin_info_list.append(plugin_info) + + return plugin_info_list diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 09566c2e78..67d0e75259 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,12 +1,13 @@ import os from pymxs import runtime as rt - +from openpype.pipeline.load import LoadError from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, - object_transform_set + object_transform_set, + get_plugins ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( @@ -29,6 +30,9 @@ class ModelUSDLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): # asset_filepath + plugin_info = get_plugins() + if "usdimport.dli" not in plugin_info: + raise LoadError("No USDImporter loaded/installed in Max..") filepath = os.path.normpath(self.filepath_from_context(context)) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(filepath) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2309d3aebf..e0317a2e22 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -2,7 +2,8 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( - unique_namespace + unique_namespace, + ) from openpype.hosts.max.api.pipeline import ( containerise, @@ -25,7 +26,6 @@ class PointCloudLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" from pymxs import runtime as rt - filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 36bc7ac9e2..daf6d3e169 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,6 +5,7 @@ from openpype.pipeline import ( load, get_representation_path ) +from openpype.pipeline.load import LoadError from openpype.hosts.max.api.pipeline import ( containerise, update_custom_attribute_data, @@ -12,7 +13,8 @@ from openpype.hosts.max.api.pipeline import ( ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace + unique_namespace, + get_plugins ) @@ -28,7 +30,9 @@ class RedshiftProxyLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - + plugin_info = get_plugins() + if "redshift4max.dlr" not in plugin_info: + raise LoadError("Redshift not loaded/installed in Max..") filepath = self.filepath_from_context(context) rs_proxy = rt.RedshiftProxy() rs_proxy.file = filepath From c292a11939a7cf3f980d018031e3da86335925c5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 19:10:52 +0800 Subject: [PATCH 384/429] typo for LoadError --- openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index d87d6b1bfe..f8aadb53c0 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -1,6 +1,6 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.pipeline.load import LoaderError +from openpype.pipeline.load import LoadError from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -30,8 +30,8 @@ class OxAbcLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): plugin_list = get_plugins() if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list: - raise LoaderError("Ornatrix plugin not " - "found/installed in Max yet..") + raise LoadError("Ornatrix plugin not " + "found/installed in Max yet..") file_path = os.path.normpath(self.filepath_from_context(context)) rt.AlembicImport.ImportToRoot = True From 8c890641ad9bb8a6f30f82fb2b668e0a6c05281d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 21:18:30 +0800 Subject: [PATCH 385/429] fixing the issue of duplciated contents during switching assets --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 4 ++-- openpype/hosts/max/plugins/load/load_max_scene.py | 7 +++---- openpype/hosts/max/plugins/load/load_model_fbx.py | 4 ++-- openpype/hosts/max/plugins/load/load_model_obj.py | 4 ++-- openpype/hosts/max/plugins/load/load_model_usd.py | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index d28364d5c2..156e8dcaf6 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -79,9 +79,9 @@ class FbxLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or fbx_object.pos + f"{fbx_object.name}.transform"] or 0 fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or fbx_object.scale + f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 86b7637c90..b7ce5bfe39 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -60,8 +60,7 @@ class MaxSceneLoader(load.LoaderPlugin): # delete old duplicate # use the modifier OP data to delete the data node_list = get_previous_loaded_object(node) - rt.Select(node_list) - prev_max_objects = rt.GetCurrentSelection() + prev_max_objects = rt.getLastMergedNodes() transform_data = object_transform_set(prev_max_objects) for prev_max_obj in prev_max_objects: if rt.isValidNode(prev_max_obj): # noqa @@ -77,9 +76,9 @@ class MaxSceneLoader(load.LoaderPlugin): max_obj.name = f"{namespace}:{obj_name}" if max_obj in node_list: max_obj.pos = transform_data[ - f"{max_obj.name}.transform"] or max_obj.pos + f"{max_obj.name}.transform"] or 0 max_obj.scale = transform_data[ - f"{max_obj.name}.scale"] or max_obj.scale + f"{max_obj.name}.scale"] or 0 lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index a8f6ea90d6..bea4d28fb7 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -73,9 +73,9 @@ class FbxModelLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or fbx_object.pos + f"{fbx_object.name}.transform"] or 0 fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or fbx_object.scale + f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 421bd34e62..ca970fb9d7 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -68,9 +68,9 @@ class ObjLoader(load.LoaderPlugin): selection.name = f"{namespace}:{selection.name}" if selection in node_list: selection.pos = transform_data[ - f"{selection.name}.transform"] or selection.pos + f"{selection.name}.transform"] or 0 selection.scale = transform_data[ - f"{selection.name}.scale"] or selection.scale + f"{selection.name}.scale"] or 0 update_custom_attribute_data(node, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 67d0e75259..6476f65a04 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -93,9 +93,9 @@ class ModelUSDLoader(load.LoaderPlugin): usd_objects.append(children) if children in node_list: children.pos = transform_data[ - f"{children.name}.transform"] or children.pos + f"{children.name}.transform"] or 0 children.scale = transform_data[ - f"{children.name}.scale"] or children.scale + f"{children.name}.scale"] or 0 asset.name = f"{namespace}:{asset.name}" usd_objects.append(asset) From 70bab9fdb8e673350e76e2e7fc18e967b86c6e8e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 21:59:57 +0800 Subject: [PATCH 386/429] fixing the issue of duplciated contents during switching assets --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 5 ++++- openpype/hosts/max/plugins/load/load_model_fbx.py | 4 +++- openpype/hosts/max/plugins/load/load_model_obj.py | 4 +++- openpype/hosts/max/plugins/load/load_model_usd.py | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 156e8dcaf6..5e4623fe4c 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -73,7 +73,10 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - current_fbx_objects = rt.GetCurrentSelection() + current_fbx_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] + update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index bea4d28fb7..9542eaa74e 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -56,7 +56,9 @@ class FbxModelLoader(load.LoaderPlugin): node_list = get_previous_loaded_object(node) rt.Select(node_list) - prev_fbx_objects = rt.GetCurrentSelection() + prev_fbx_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index ca970fb9d7..38ba5e3e8f 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -55,7 +55,9 @@ class ObjLoader(load.LoaderPlugin): namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - previous_objects = rt.GetCurrentSelection() + previous_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] transform_data = object_transform_set(previous_objects) for prev_obj in previous_objects: if rt.isValidNode(prev_obj): diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 6476f65a04..cabcdaa6b5 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -71,7 +71,9 @@ class ModelUSDLoader(load.LoaderPlugin): namespace, name = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - prev_objects = rt.GetCurrentSelection() + prev_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] transform_data = object_transform_set(prev_objects) for n in prev_objects: rt.Delete(n) From c53abb50f07c5e7e1df185425e034f744d2314a8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 22:06:11 +0800 Subject: [PATCH 387/429] fixing the issue of duplciated contents during switching assets --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 2 +- openpype/hosts/max/plugins/load/load_model_fbx.py | 2 +- openpype/hosts/max/plugins/load/load_model_usd.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 5e4623fe4c..1f891e19b3 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -75,7 +75,7 @@ class FbxLoader(load.LoaderPlugin): path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container - and sel.name == node_name] + and sel.name != node_name] update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 9542eaa74e..cdc5667d78 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -58,7 +58,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.Select(node_list) prev_fbx_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container - and sel.name == node_name] + and sel.name != node_name] transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index cabcdaa6b5..38233cfd62 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -73,7 +73,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.Select(node_list) prev_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container - and sel.name == node_name] + and sel.name != node_name] transform_data = object_transform_set(prev_objects) for n in prev_objects: rt.Delete(n) From 5f2756b95ee0d06b1ac2748b736db93dc47a7f46 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 13 Sep 2023 22:42:33 +0200 Subject: [PATCH 388/429] Skip view capture when Maya is in headless mode Co-authored-by: Toke Jepsen --- .../plugins/publish/extract_active_view_thumbnail.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py index b4e62f8acc..71a0ba877b 100644 --- a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -4,6 +4,9 @@ import maya.api.OpenMayaUI as omui import pyblish.api import tempfile +from openpype.hosts.maya.lib import IS_HEADLESS + + class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): """Set instance thumbnail to a screengrab of current active viewport. @@ -19,6 +22,13 @@ class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): hosts = ["maya"] def process(self, instance): + if IS_HEADLESS: + self.log.debug( + "Skip extraction of active view thumbnail, due to being in" + "headless mode." + ) + return + thumbnail = instance.data.get("thumbnailPath") if not thumbnail: view_thumbnail = self.get_view_thumbnail(instance) From ef843e25ca35b2e0ab6b0be47f214093131e9c7c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 13 Sep 2023 23:01:58 +0200 Subject: [PATCH 389/429] Update openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py --- .../hosts/maya/plugins/publish/extract_active_view_thumbnail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py index 71a0ba877b..f47dd5e084 100644 --- a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -7,7 +7,6 @@ import tempfile from openpype.hosts.maya.lib import IS_HEADLESS - class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): """Set instance thumbnail to a screengrab of current active viewport. From 559021b5f6d42d644ed0efde7ae09ccba2399c6f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 16:27:49 +0800 Subject: [PATCH 390/429] fix the bug of the 3dsmax OP data not being collected during switching version --- .../hosts/max/plugins/load/load_camera_fbx.py | 18 ++++++------- .../hosts/max/plugins/load/load_max_scene.py | 25 ++++++++++++------- .../hosts/max/plugins/load/load_model_fbx.py | 17 +++++++------ .../hosts/max/plugins/load/load_model_obj.py | 10 +++----- .../hosts/max/plugins/load/load_model_usd.py | 6 ++--- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 1f891e19b3..ce1427a980 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -73,22 +73,18 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - current_fbx_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name != node_name] - - update_custom_attribute_data(node, current_fbx_objects) + current_fbx_objects = rt.GetCurrentSelection() + fbx_objects = [] for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" - if fbx_object in node_list: - fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or 0 + fbx_objects.append(fbx_object) + fbx_transform = f"{fbx_object.name}.transform" + if fbx_transform in transform_data.keys(): + fbx_object.pos = transform_data[fbx_transform] or 0 fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] or 0 - with maintained_selection(): - rt.Select(node) - + update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index b7ce5bfe39..4b66dbff6f 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -43,7 +43,6 @@ class MaxSceneLoader(load.LoaderPlugin): for max_obj, obj_name in zip(max_objects, max_object_names): max_obj.name = f"{namespace}:{obj_name}" max_container.append(rt.getNodeByName(max_obj.name)) - return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -53,32 +52,40 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - + print(node_name) node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) # delete the old container with attribute # delete old duplicate # use the modifier OP data to delete the data node_list = get_previous_loaded_object(node) - prev_max_objects = rt.getLastMergedNodes() + rt.select(node_list) + prev_max_objects = rt.GetCurrentSelection() + print(f"{node_list}") transform_data = object_transform_set(prev_max_objects) + for prev_max_obj in prev_max_objects: if rt.isValidNode(prev_max_obj): # noqa rt.Delete(prev_max_obj) - rt.MergeMaxFile(path, rt.Name("deleteOldDups")) + rt.MergeMaxFile(path, quiet=True) current_max_objects = rt.getLastMergedNodes() + current_max_object_names = [obj.name for obj in current_max_objects] - update_custom_attribute_data(node, current_max_objects) + + max_objects = [] for max_obj, obj_name in zip(current_max_objects, - current_max_object_names): + current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" - if max_obj in node_list: - max_obj.pos = transform_data[ - f"{max_obj.name}.transform"] or 0 + max_objects.append(max_obj) + max_transform = f"{max_obj.name}.transform" + if max_transform in transform_data.keys(): + max_obj.pos = transform_data[max_transform] or 0 max_obj.scale = transform_data[ f"{max_obj.name}.scale"] or 0 + + update_custom_attribute_data(node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index cdc5667d78..71fc382eed 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -52,13 +52,13 @@ class FbxModelLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) + if not node: + rt.Container(name=node_name) namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - prev_fbx_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name != node_name] + prev_fbx_objects = rt.GetCurrentSelection() transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): @@ -70,18 +70,19 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() - update_custom_attribute_data(node, current_fbx_objects) + fbx_objects = [] for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" - if fbx_object in node_list: - fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or 0 + fbx_objects.append(fbx_object) + fbx_transform = f"{fbx_object.name}.transform" + if fbx_transform in transform_data.keys(): + fbx_object.pos = transform_data[fbx_transform] or 0 fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) - + update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 38ba5e3e8f..aedb288a2d 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -55,9 +55,7 @@ class ObjLoader(load.LoaderPlugin): namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - previous_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name == node_name] + previous_objects = rt.GetCurrentSelection() transform_data = object_transform_set(previous_objects) for prev_obj in previous_objects: if rt.isValidNode(prev_obj): @@ -68,9 +66,9 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.name = f"{namespace}:{selection.name}" - if selection in node_list: - selection.pos = transform_data[ - f"{selection.name}.transform"] or 0 + selection_transform = f"{selection.name}.transform" + if selection_transform in transform_data.keys(): + selection.pos = transform_data[selection_transform] or 0 selection.scale = transform_data[ f"{selection.name}.scale"] or 0 update_custom_attribute_data(node, selections) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 38233cfd62..bce4bd4a9a 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -93,9 +93,9 @@ class ModelUSDLoader(load.LoaderPlugin): for children in asset.Children: children.name = f"{namespace}:{children.name}" usd_objects.append(children) - if children in node_list: - children.pos = transform_data[ - f"{children.name}.transform"] or 0 + children_transform = f"{children.name}.transform" + if children_transform in transform_data.keys(): + children.pos = transform_data[children_transform] or 0 children.scale = transform_data[ f"{children.name}.scale"] or 0 From cad715ad0182cbfaa2093b7217723170af293887 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 16:32:04 +0800 Subject: [PATCH 391/429] remove print & hound --- openpype/hosts/max/plugins/load/load_max_scene.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 4b66dbff6f..0b5f0a2858 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -52,7 +52,6 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - print(node_name) node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) # delete the old container with attribute @@ -61,7 +60,6 @@ class MaxSceneLoader(load.LoaderPlugin): node_list = get_previous_loaded_object(node) rt.select(node_list) prev_max_objects = rt.GetCurrentSelection() - print(f"{node_list}") transform_data = object_transform_set(prev_max_objects) for prev_max_obj in prev_max_objects: @@ -76,7 +74,7 @@ class MaxSceneLoader(load.LoaderPlugin): max_objects = [] for max_obj, obj_name in zip(current_max_objects, - current_max_object_names): + current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" max_objects.append(max_obj) max_transform = f"{max_obj.name}.transform" From 5e20dd3f9cb553c053f35baf0fb321e6b46a3f43 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 09:54:32 +0100 Subject: [PATCH 392/429] Update openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py --- .../hosts/maya/plugins/publish/extract_active_view_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py index f47dd5e084..483ae6d9d3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -4,7 +4,7 @@ import maya.api.OpenMayaUI as omui import pyblish.api import tempfile -from openpype.hosts.maya.lib import IS_HEADLESS +from openpype.hosts.maya.api.lib import IS_HEADLESS class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): From cdc6366662b00a3e0f413cdba8e1eb1784f10e28 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 17:42:35 +0800 Subject: [PATCH 393/429] update the OP data after the merge of #5424 --- .../plugins/load/load_pointcache_ornatrix.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index f8aadb53c0..0f32b7938f 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -50,19 +50,13 @@ class OxAbcLoader(load.LoaderPlugin): name + "_", suffix="_", ) - - abc_container = rt.Container() + abc_container =[] for abc in scene_object: - abc.Parent = abc_container abc.name = f"{namespace}:{abc.name}" - # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}_{self.postfix}" - abc_container.name = abc_container_name - import_custom_attribute_data( - abc_container, abc_container.Children) + abc_container.append(abc) return containerise( - name, [abc_container], context, + name, abc_container, context, namespace, loader=self.__class__.__name__ ) @@ -89,14 +83,17 @@ class OxAbcLoader(load.LoaderPlugin): obj_type = rt.ClassOf(obj) if str(obj_type).startswith("Ox_"): scene_object.append(obj) - update_custom_attribute_data( - inst_container, scene_object.Children) + ox_abc_objects = [] for abc in scene_object: abc.Parent = container abc.name = f"{namespace}:{abc.name}" - abc.pos = transform_data[f"{abc.name}.transform"] - abc.scale = transform_data[f"{abc.name}.scale"] - + ox_abc_objects.append(abc) + ox_transform = f"{abc.name}.transform" + if ox_transform in transform_data.keys(): + abc.pos = transform_data[ox_transform] or 0 + abc.scale = transform_data[f"{abc.name}.scale"] or 0 + update_custom_attribute_data( + inst_container, ox_abc_objects) lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, From 2bd4d295afda0e5f64434e6119d802180f4ca618 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 17:46:32 +0800 Subject: [PATCH 394/429] update the Data & hound --- .../plugins/load/load_pointcache_ornatrix.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index 0f32b7938f..cbd15ae6b8 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -3,7 +3,7 @@ from openpype.pipeline import load, get_representation_path from openpype.pipeline.load import LoadError from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) @@ -50,7 +50,7 @@ class OxAbcLoader(load.LoaderPlugin): name + "_", suffix="_", ) - abc_container =[] + abc_container = [] for abc in scene_object: abc.name = f"{namespace}:{abc.name}" abc_container.append(abc) @@ -64,11 +64,12 @@ class OxAbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_obj in rt.selection: + node = rt.getNodeByName(node_name) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + selections = rt.getCurrentSelection() + transform_data = object_transform_set(selections) + for prev_obj in selections: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) @@ -92,8 +93,7 @@ class OxAbcLoader(load.LoaderPlugin): if ox_transform in transform_data.keys(): abc.pos = transform_data[ox_transform] or 0 abc.scale = transform_data[f"{abc.name}.scale"] or 0 - update_custom_attribute_data( - inst_container, ox_abc_objects) + update_custom_attribute_data(node, ox_abc_objects) lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, From 963f3f42ff287742e6aa2004292dc513464bcaa8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 17:48:18 +0800 Subject: [PATCH 395/429] use the get_plugins function from max.api.lib --- .../max/plugins/load/load_pointcache_ornatrix.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py index cbd15ae6b8..96060a6a6f 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -10,7 +10,8 @@ from openpype.hosts.max.api.pipeline import ( from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, - object_transform_set + object_transform_set, + get_plugins ) from openpype.hosts.max.api import lib from pymxs import runtime as rt @@ -105,15 +106,3 @@ class OxAbcLoader(load.LoaderPlugin): def remove(self, container): node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) - - -def get_plugins() -> list: - """Get plugin list from 3ds max.""" - manager = rt.PluginManager - count = manager.pluginDllCount - plugin_info_list = [] - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list From 93fb76f359c1d511eb491667a771088e02bb5131 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:55:25 +0200 Subject: [PATCH 396/429] Extract Review: Multilayer specification for ffmpeg (#5613) * added function to extract more information about channels * specify layer name which should be used for ffmpeg * changed 'get_channels_info_by_layer_name' to 'get_review_info_by_layer_name' * modify docstring * fix dosctring again --- openpype/lib/transcoding.py | 168 ++++++++++++++++----- openpype/plugins/publish/extract_review.py | 32 +++- 2 files changed, 160 insertions(+), 40 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 6e323f55c1..97c8dd41ab 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -315,6 +315,92 @@ def parse_oiio_xml_output(xml_string, logger=None): return output +def get_review_info_by_layer_name(channel_names): + """Get channels info grouped by layer name. + + Finds all layers in channel names and returns list of dictionaries with + information about channels in layer. + Example output (not real world example): + [ + { + "name": "Main", + "review_channels": { + "R": "Main.red", + "G": "Main.green", + "B": "Main.blue", + "A": None, + } + }, + { + "name": "Composed", + "review_channels": { + "R": "Composed.R", + "G": "Composed.G", + "B": "Composed.B", + "A": "Composed.A", + } + }, + ... + ] + + Args: + channel_names (list[str]): List of channel names. + + Returns: + list[dict]: List of channels information. + """ + + layer_names_order = [] + rgba_by_layer_name = collections.defaultdict(dict) + channels_by_layer_name = collections.defaultdict(dict) + + for channel_name in channel_names: + layer_name = "" + last_part = channel_name + if "." in channel_name: + layer_name, last_part = channel_name.rsplit(".", 1) + + channels_by_layer_name[layer_name][channel_name] = last_part + if last_part.lower() not in { + "r", "red", + "g", "green", + "b", "blue", + "a", "alpha" + }: + continue + + if layer_name not in layer_names_order: + layer_names_order.append(layer_name) + # R, G, B or A + channel = last_part[0].upper() + rgba_by_layer_name[layer_name][channel] = channel_name + + # Put empty layer to the beginning of the list + # - if input has R, G, B, A channels they should be used for review + if "" in layer_names_order: + layer_names_order.remove("") + layer_names_order.insert(0, "") + + output = [] + for layer_name in layer_names_order: + rgba_layer_info = rgba_by_layer_name[layer_name] + red = rgba_layer_info.get("R") + green = rgba_layer_info.get("G") + blue = rgba_layer_info.get("B") + if not red or not green or not blue: + continue + output.append({ + "name": layer_name, + "review_channels": { + "R": red, + "G": green, + "B": blue, + "A": rgba_layer_info.get("A"), + } + }) + return output + + def get_convert_rgb_channels(channel_names): """Get first available RGB(A) group from channels info. @@ -323,7 +409,7 @@ def get_convert_rgb_channels(channel_names): # Ideal situation channels_info: [ "R", "G", "B", "A" - } + ] ``` Result will be `("R", "G", "B", "A")` @@ -331,50 +417,60 @@ def get_convert_rgb_channels(channel_names): # Not ideal situation channels_info: [ "beauty.red", - "beuaty.green", + "beauty.green", "beauty.blue", "depth.Z" ] ``` Result will be `("beauty.red", "beauty.green", "beauty.blue", None)` + Args: + channel_names (list[str]): List of channel names. + Returns: - NoneType: There is not channel combination that matches RGB - combination. - tuple: Tuple of 4 channel names defying channel names for R, G, B, A - where A can be None. + Union[NoneType, tuple[str, str, str, Union[str, None]]]: Tuple of + 4 channel names defying channel names for R, G, B, A or None + if there is not any layer with RGB combination. """ - rgb_by_main_name = collections.defaultdict(dict) - main_name_order = [""] - for channel_name in channel_names: - name_parts = channel_name.split(".") - rgb_part = name_parts.pop(-1).lower() - main_name = ".".join(name_parts) - if rgb_part in ("r", "red"): - rgb_by_main_name[main_name]["R"] = channel_name - elif rgb_part in ("g", "green"): - rgb_by_main_name[main_name]["G"] = channel_name - elif rgb_part in ("b", "blue"): - rgb_by_main_name[main_name]["B"] = channel_name - elif rgb_part in ("a", "alpha"): - rgb_by_main_name[main_name]["A"] = channel_name - else: - continue - if main_name not in main_name_order: - main_name_order.append(main_name) - output = None - for main_name in main_name_order: - colors = rgb_by_main_name.get(main_name) or {} - red = colors.get("R") - green = colors.get("G") - blue = colors.get("B") - alpha = colors.get("A") - if red is not None and green is not None and blue is not None: - output = (red, green, blue, alpha) - break + channels_info = get_review_info_by_layer_name(channel_names) + for item in channels_info: + review_channels = item["review_channels"] + return ( + review_channels["R"], + review_channels["G"], + review_channels["B"], + review_channels["A"] + ) + return None - return output + +def get_review_layer_name(src_filepath): + """Find layer name that could be used for review. + + Args: + src_filepath (str): Path to input file. + + Returns: + Union[str, None]: Layer name of None. + """ + + ext = os.path.splitext(src_filepath)[-1].lower() + if ext != ".exr": + return None + + # Load info about file from oiio tool + input_info = get_oiio_info_for_input(src_filepath) + if not input_info: + return None + + channel_names = input_info["channelnames"] + channels_info = get_review_info_by_layer_name(channel_names) + for item in channels_info: + # Layer name can be '', when review channels are 'R', 'G', 'B' + # without layer + return item["name"] or None + return None def should_convert_for_ffmpeg(src_filepath): @@ -395,7 +491,7 @@ def should_convert_for_ffmpeg(src_filepath): if not is_oiio_supported(): return None - # Load info about info from oiio tool + # Load info about file from oiio tool input_info = get_oiio_info_for_input(src_filepath) if not input_info: return None diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 9cc456872e..0ae941511c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -21,6 +21,7 @@ from openpype.lib.transcoding import ( IMAGE_EXTENSIONS, get_ffprobe_streams, should_convert_for_ffmpeg, + get_review_layer_name, convert_input_paths_for_ffmpeg, get_transcode_temp_directory, ) @@ -266,6 +267,8 @@ class ExtractReview(pyblish.api.InstancePlugin): )) continue + layer_name = get_review_layer_name(first_input_path) + # Do conversion if needed # - change staging dir of source representation # - must be set back after output definitions processing @@ -284,7 +287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): instance, repre, src_repre_staging_dir, - filtered_output_defs + filtered_output_defs, + layer_name ) finally: @@ -298,7 +302,12 @@ class ExtractReview(pyblish.api.InstancePlugin): shutil.rmtree(new_staging_dir) def _render_output_definitions( - self, instance, repre, src_repre_staging_dir, output_definitions + self, + instance, + repre, + src_repre_staging_dir, + output_definitions, + layer_name ): fill_data = copy.deepcopy(instance.data["anatomyData"]) for _output_def in output_definitions: @@ -370,7 +379,12 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data, fill_data + output_def, + instance, + new_repre, + temp_data, + fill_data, + layer_name, ) except ZeroDivisionError: # TODO recalculate width and height using OIIO before @@ -531,7 +545,13 @@ class ExtractReview(pyblish.api.InstancePlugin): } def _ffmpeg_arguments( - self, output_def, instance, new_repre, temp_data, fill_data + self, + output_def, + instance, + new_repre, + temp_data, + fill_data, + layer_name ): """Prepares ffmpeg arguments for expected extraction. @@ -599,6 +619,10 @@ class ExtractReview(pyblish.api.InstancePlugin): duration_seconds = float(output_frames_len / temp_data["fps"]) + # Define which layer should be used + if layer_name: + ffmpeg_input_args.extend(["-layer", layer_name]) + if temp_data["input_is_sequence"]: # Set start frame of input sequence (just frame in filename) # - definition of input filepath From 8729cf023c3138e5c69b32dac21eb6def73c41fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Sep 2023 18:57:23 +0200 Subject: [PATCH 397/429] Publisher: Fix screenshot widget (#5615) * fix called super method name * remove fade animation * use paths to draw background * use same opacity for lines * add render hints * minor cleanup --- .../publisher/widgets/screenshot_widget.py | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 64cccece6c..3504b419b4 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -26,14 +26,6 @@ class ScreenMarquee(QtWidgets.QDialog): self.setCursor(QtCore.Qt.CrossCursor) self.setMouseTracking(True) - fade_anim = QtCore.QVariantAnimation() - fade_anim.setStartValue(0) - fade_anim.setEndValue(50) - fade_anim.setDuration(200) - fade_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic) - - fade_anim.valueChanged.connect(self._on_fade_anim) - app = QtWidgets.QApplication.instance() if hasattr(app, "screenAdded"): app.screenAdded.connect(self._on_screen_added) @@ -45,12 +37,10 @@ class ScreenMarquee(QtWidgets.QDialog): for screen in QtWidgets.QApplication.screens(): screen.geometryChanged.connect(self._fit_screen_geometry) - self._opacity = fade_anim.startValue() + self._opacity = 50 self._click_pos = None self._capture_rect = None - self._fade_anim = fade_anim - def get_captured_pixmap(self): if self._capture_rect is None: return QtGui.QPixmap() @@ -67,28 +57,33 @@ class ScreenMarquee(QtWidgets.QDialog): click_pos = self.mapFromGlobal(self._click_pos) painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) # Draw background. Aside from aesthetics, this makes the full # tool region accept mouse events. painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) painter.setPen(QtCore.Qt.NoPen) - painter.drawRect(event.rect()) + rect = event.rect() + fill_path = QtGui.QPainterPath() + fill_path.addRect(rect) # Clear the capture area if click_pos is not None: + sub_path = QtGui.QPainterPath() capture_rect = QtCore.QRect(click_pos, mouse_pos) - painter.setCompositionMode( - QtGui.QPainter.CompositionMode_Clear) - painter.drawRect(capture_rect) - painter.setCompositionMode( - QtGui.QPainter.CompositionMode_SourceOver) + sub_path.addRect(capture_rect) + fill_path = fill_path.subtracted(sub_path) - pen_color = QtGui.QColor(255, 255, 255, 64) + painter.drawPath(fill_path) + + pen_color = QtGui.QColor(255, 255, 255, self._opacity) pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) painter.setPen(pen) # Draw cropping markers at click position - rect = event.rect() if click_pos is not None: painter.drawLine( rect.left(), click_pos.y(), @@ -108,6 +103,7 @@ class ScreenMarquee(QtWidgets.QDialog): mouse_pos.x(), rect.top(), mouse_pos.x(), rect.bottom() ) + painter.end() def mousePressEvent(self, event): """Mouse click event""" @@ -138,13 +134,13 @@ class ScreenMarquee(QtWidgets.QDialog): if event.key() == QtCore.Qt.Key_Escape: self._click_pos = None self._capture_rect = None + event.accept() self.close() return - return super(ScreenMarquee, self).mousePressEvent(event) + return super(ScreenMarquee, self).keyPressEvent(event) def showEvent(self, event): self._fit_screen_geometry() - self._fade_anim.start() def _fit_screen_geometry(self): # Compute the union of all screen geometries, and resize to fit. @@ -153,12 +149,6 @@ class ScreenMarquee(QtWidgets.QDialog): workspace_rect = workspace_rect.united(screen.geometry()) self.setGeometry(workspace_rect) - def _on_fade_anim(self): - """Animation callback for opacity.""" - - self._opacity = self._fade_anim.currentValue() - self.repaint() - def _on_screen_added(self): for screen in QtGui.QGuiApplication.screens(): screen.geometryChanged.connect(self._fit_screen_geometry) From 254a1859632b9b5ce59fb58c8cdb47a754ae746d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 22:35:04 +0300 Subject: [PATCH 398/429] set PATH environment in deadline jobs --- .../repository/custom/plugins/GlobalJobPreLoad.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 97875215ae..79cd3968fb 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -385,6 +385,11 @@ def inject_openpype_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + if "PATH" in contents: + PATH = contents["PATH"] + print(f">>> Set 'PATH' Environment to: {PATH}") + os.environ["PATH"] = PATH + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") @@ -509,6 +514,11 @@ def inject_ayon_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + if "PATH" in contents: + PATH = contents["PATH"] + print(f">>> Set 'PATH' Environment to: {PATH}") + os.environ["PATH"] = PATH + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") From f7cc7f1908948fc0aa1526987b8cc12a20dd4736 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 23:17:52 +0300 Subject: [PATCH 399/429] BigRoy's comment --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 79cd3968fb..24bfd983f3 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -387,7 +387,7 @@ def inject_openpype_environment(deadlinePlugin): if "PATH" in contents: PATH = contents["PATH"] - print(f">>> Set 'PATH' Environment to: {PATH}") + print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") @@ -516,7 +516,7 @@ def inject_ayon_environment(deadlinePlugin): if "PATH" in contents: PATH = contents["PATH"] - print(f">>> Set 'PATH' Environment to: {PATH}") + print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") From 1ec11da6f7c25550edd030461845b88681c1b4a6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 23:23:31 +0300 Subject: [PATCH 400/429] add a developer note --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 24bfd983f3..044a953083 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -386,6 +386,8 @@ def inject_openpype_environment(deadlinePlugin): deadlinePlugin.SetProcessEnvironmentVariable(key, value) if "PATH" in contents: + # Set os.environ[PATH] so studio settings' path entries + # can be used to define search path for executables. PATH = contents["PATH"] print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH @@ -515,6 +517,8 @@ def inject_ayon_environment(deadlinePlugin): deadlinePlugin.SetProcessEnvironmentVariable(key, value) if "PATH" in contents: + # Set os.environ[PATH] so studio settings' path entries + # can be used to define search path for executables. PATH = contents["PATH"] print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH From 0c09a6772ce3c0f3c9ce47547e937e9dc254d6fe Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 15 Sep 2023 09:50:13 +0300 Subject: [PATCH 401/429] BigRoy's comment --- .../repository/custom/plugins/GlobalJobPreLoad.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 044a953083..e9b81369ca 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -388,9 +388,8 @@ def inject_openpype_environment(deadlinePlugin): if "PATH" in contents: # Set os.environ[PATH] so studio settings' path entries # can be used to define search path for executables. - PATH = contents["PATH"] - print(f">>> Setting 'PATH' Environment to: {PATH}") - os.environ["PATH"] = PATH + print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") + os.environ["PATH"] = contents["PATH"] script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: @@ -519,9 +518,8 @@ def inject_ayon_environment(deadlinePlugin): if "PATH" in contents: # Set os.environ[PATH] so studio settings' path entries # can be used to define search path for executables. - PATH = contents["PATH"] - print(f">>> Setting 'PATH' Environment to: {PATH}") - os.environ["PATH"] = PATH + print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") + os.environ["PATH"] = contents["PATH"] script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: From b50258874360687b4a1e36eff9935084db526456 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 15 Sep 2023 10:54:53 +0200 Subject: [PATCH 402/429] Fix - images without alpha will not fail (#5620) --- .../photoshop/plugins/publish/extract_review.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index afddbdba31..d5dac417d7 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -140,10 +140,14 @@ class ExtractReview(publish.Extractor): _, ext = os.path.splitext(repre_file["files"]) if ext != ".jpg": im = Image.open(source_file_path) - # without this it produces messy low quality jpg - rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") - rgb_im.alpha_composite(im) - rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + if (im.mode in ('RGBA', 'LA') or ( + im.mode == 'P' and 'transparency' in im.info)): + # without this it produces messy low quality jpg + rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") + rgb_im.alpha_composite(im) + rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + else: + im.save(os.path.join(staging_dir, img_file)) else: # handles already .jpg shutil.copy(source_file_path, From 67abbaad3c2bdbbf561bc7d30d6ac1adc5b21ed8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 15 Sep 2023 11:02:37 +0200 Subject: [PATCH 403/429] Fussion: added support for Fusion 17 (#5614) * OP-6780 - vendorized necessary libraries for Python 3.6 * OP-6780 - better resolution of app_version * Update openpype/hosts/fusion/addon.py Co-authored-by: Roy Nieterau * OP-6780 - add vendorized libraries even before menu creation This should help when version of Fusion > 17, but it is still using 3.6 * OP-6780 - added todo message to remember to remove this * OP-6780 - move injection of PYTHONPATH much sooner At previous position it was too late. * OP-6780 - better capture of broken imports * OP-6780 - SyntaxError is thrown only if directly importing * OP-6780 - remove unnecessary imports Only urllib3 and attrs are actually needed * OP-6780 - vendorize even directly in Fusion if Python < 3.7 * OP-6780 - remove update of PYTHONPATH in addon More important and required is check directly in interpreter in Fusion, it doesn't make sense to pollute addon and have it on two places. It might get removed altogether in next-minor. * OP-6780 - added comment --------- Co-authored-by: Roy Nieterau --- openpype/hosts/fusion/addon.py | 3 +- .../deploy/MenuScripts/openpype_menu.py | 13 + openpype/hosts/fusion/vendor/attr/__init__.py | 78 + .../hosts/fusion/vendor/attr/__init__.pyi | 475 +++ openpype/hosts/fusion/vendor/attr/_cmp.py | 152 + openpype/hosts/fusion/vendor/attr/_cmp.pyi | 14 + openpype/hosts/fusion/vendor/attr/_compat.py | 242 ++ openpype/hosts/fusion/vendor/attr/_config.py | 23 + openpype/hosts/fusion/vendor/attr/_funcs.py | 395 +++ openpype/hosts/fusion/vendor/attr/_make.py | 3052 +++++++++++++++++ .../hosts/fusion/vendor/attr/_next_gen.py | 158 + .../hosts/fusion/vendor/attr/_version_info.py | 85 + .../fusion/vendor/attr/_version_info.pyi | 9 + .../hosts/fusion/vendor/attr/converters.py | 111 + .../hosts/fusion/vendor/attr/converters.pyi | 13 + .../hosts/fusion/vendor/attr/exceptions.py | 92 + .../hosts/fusion/vendor/attr/exceptions.pyi | 18 + openpype/hosts/fusion/vendor/attr/filters.py | 52 + openpype/hosts/fusion/vendor/attr/filters.pyi | 7 + openpype/hosts/fusion/vendor/attr/py.typed | 0 openpype/hosts/fusion/vendor/attr/setters.py | 77 + openpype/hosts/fusion/vendor/attr/setters.pyi | 20 + .../hosts/fusion/vendor/attr/validators.py | 379 ++ .../hosts/fusion/vendor/attr/validators.pyi | 68 + .../hosts/fusion/vendor/urllib3/__init__.py | 85 + .../fusion/vendor/urllib3/_collections.py | 337 ++ .../hosts/fusion/vendor/urllib3/_version.py | 2 + .../hosts/fusion/vendor/urllib3/connection.py | 539 +++ .../fusion/vendor/urllib3/connectionpool.py | 1067 ++++++ .../fusion/vendor/urllib3/contrib/__init__.py | 0 .../urllib3/contrib/_appengine_environ.py | 36 + .../contrib/_securetransport/__init__.py | 0 .../contrib/_securetransport/bindings.py | 519 +++ .../contrib/_securetransport/low_level.py | 396 +++ .../vendor/urllib3/contrib/appengine.py | 314 ++ .../fusion/vendor/urllib3/contrib/ntlmpool.py | 130 + .../vendor/urllib3/contrib/pyopenssl.py | 511 +++ .../vendor/urllib3/contrib/securetransport.py | 922 +++++ .../fusion/vendor/urllib3/contrib/socks.py | 216 ++ .../hosts/fusion/vendor/urllib3/exceptions.py | 323 ++ .../hosts/fusion/vendor/urllib3/fields.py | 274 ++ .../hosts/fusion/vendor/urllib3/filepost.py | 98 + .../vendor/urllib3/packages/__init__.py | 5 + .../urllib3/packages/backports/__init__.py | 0 .../urllib3/packages/backports/makefile.py | 51 + .../fusion/vendor/urllib3/packages/six.py | 1077 ++++++ .../packages/ssl_match_hostname/__init__.py | 24 + .../ssl_match_hostname/_implementation.py | 160 + .../fusion/vendor/urllib3/poolmanager.py | 536 +++ .../hosts/fusion/vendor/urllib3/request.py | 170 + .../hosts/fusion/vendor/urllib3/response.py | 821 +++++ .../fusion/vendor/urllib3/util/__init__.py | 49 + .../fusion/vendor/urllib3/util/connection.py | 150 + .../hosts/fusion/vendor/urllib3/util/proxy.py | 56 + .../hosts/fusion/vendor/urllib3/util/queue.py | 22 + .../fusion/vendor/urllib3/util/request.py | 143 + .../fusion/vendor/urllib3/util/response.py | 107 + .../hosts/fusion/vendor/urllib3/util/retry.py | 602 ++++ .../hosts/fusion/vendor/urllib3/util/ssl_.py | 495 +++ .../vendor/urllib3/util/ssltransport.py | 221 ++ .../fusion/vendor/urllib3/util/timeout.py | 268 ++ .../hosts/fusion/vendor/urllib3/util/url.py | 432 +++ .../hosts/fusion/vendor/urllib3/util/wait.py | 153 + 63 files changed, 16846 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/fusion/vendor/attr/__init__.py create mode 100644 openpype/hosts/fusion/vendor/attr/__init__.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/_cmp.py create mode 100644 openpype/hosts/fusion/vendor/attr/_cmp.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/_compat.py create mode 100644 openpype/hosts/fusion/vendor/attr/_config.py create mode 100644 openpype/hosts/fusion/vendor/attr/_funcs.py create mode 100644 openpype/hosts/fusion/vendor/attr/_make.py create mode 100644 openpype/hosts/fusion/vendor/attr/_next_gen.py create mode 100644 openpype/hosts/fusion/vendor/attr/_version_info.py create mode 100644 openpype/hosts/fusion/vendor/attr/_version_info.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/converters.py create mode 100644 openpype/hosts/fusion/vendor/attr/converters.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/exceptions.py create mode 100644 openpype/hosts/fusion/vendor/attr/exceptions.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/filters.py create mode 100644 openpype/hosts/fusion/vendor/attr/filters.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/py.typed create mode 100644 openpype/hosts/fusion/vendor/attr/setters.py create mode 100644 openpype/hosts/fusion/vendor/attr/setters.pyi create mode 100644 openpype/hosts/fusion/vendor/attr/validators.py create mode 100644 openpype/hosts/fusion/vendor/attr/validators.pyi create mode 100644 openpype/hosts/fusion/vendor/urllib3/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/_collections.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/_version.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/connection.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/connectionpool.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/contrib/socks.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/exceptions.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/fields.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/filepost.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/packages/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/packages/backports/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/packages/six.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/poolmanager.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/request.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/response.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/__init__.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/connection.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/proxy.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/queue.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/request.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/response.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/retry.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/ssl_.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/timeout.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/url.py create mode 100644 openpype/hosts/fusion/vendor/urllib3/util/wait.py diff --git a/openpype/hosts/fusion/addon.py b/openpype/hosts/fusion/addon.py index 45683cfbde..8343f3c79d 100644 --- a/openpype/hosts/fusion/addon.py +++ b/openpype/hosts/fusion/addon.py @@ -60,8 +60,9 @@ class FusionAddon(OpenPypeModule, IHostAddon): return [] return [os.path.join(FUSION_HOST_DIR, "hooks")] - def add_implementation_envs(self, env, _app): + def add_implementation_envs(self, env, app): # Set default values if are not already set via settings + defaults = {"OPENPYPE_LOG_NO_COLORS": "Yes"} for key, value in defaults.items(): if not env.get(key): diff --git a/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py b/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py index 685e58d58f..1c58ee50e4 100644 --- a/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py +++ b/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py @@ -1,6 +1,19 @@ import os import sys +if sys.version_info < (3, 7): + # hack to handle discrepancy between distributed libraries and Python 3.6 + # mostly because wrong version of urllib3 + # TODO remove when not necessary + from openpype import PACKAGE_DIR + FUSION_HOST_DIR = os.path.join(PACKAGE_DIR, "hosts", "fusion") + + vendor_path = os.path.join(FUSION_HOST_DIR, "vendor") + if vendor_path not in sys.path: + sys.path.insert(0, vendor_path) + + print(f"Added vendorized libraries from {vendor_path}") + from openpype.lib import Logger from openpype.pipeline import ( install_host, diff --git a/openpype/hosts/fusion/vendor/attr/__init__.py b/openpype/hosts/fusion/vendor/attr/__init__.py new file mode 100644 index 0000000000..b1ce7fe248 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/__init__.py @@ -0,0 +1,78 @@ +from __future__ import absolute_import, division, print_function + +import sys + +from functools import partial + +from . import converters, exceptions, filters, setters, validators +from ._cmp import cmp_using +from ._config import get_run_validators, set_run_validators +from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types +from ._make import ( + NOTHING, + Attribute, + Factory, + attrib, + attrs, + fields, + fields_dict, + make_class, + validate, +) +from ._version_info import VersionInfo + + +__version__ = "21.2.0" +__version_info__ = VersionInfo._from_version_string(__version__) + +__title__ = "attrs" +__description__ = "Classes Without Boilerplate" +__url__ = "https://www.attrs.org/" +__uri__ = __url__ +__doc__ = __description__ + " <" + __uri__ + ">" + +__author__ = "Hynek Schlawack" +__email__ = "hs@ox.cx" + +__license__ = "MIT" +__copyright__ = "Copyright (c) 2015 Hynek Schlawack" + + +s = attributes = attrs +ib = attr = attrib +dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) + +__all__ = [ + "Attribute", + "Factory", + "NOTHING", + "asdict", + "assoc", + "astuple", + "attr", + "attrib", + "attributes", + "attrs", + "cmp_using", + "converters", + "evolve", + "exceptions", + "fields", + "fields_dict", + "filters", + "get_run_validators", + "has", + "ib", + "make_class", + "resolve_types", + "s", + "set_run_validators", + "setters", + "validate", + "validators", +] + +if sys.version_info[:2] >= (3, 6): + from ._next_gen import define, field, frozen, mutable + + __all__.extend((define, field, frozen, mutable)) diff --git a/openpype/hosts/fusion/vendor/attr/__init__.pyi b/openpype/hosts/fusion/vendor/attr/__init__.pyi new file mode 100644 index 0000000000..3503b073b4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/__init__.pyi @@ -0,0 +1,475 @@ +import sys + +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + overload, +) + +# `import X as X` is required to make these public +from . import converters as converters +from . import exceptions as exceptions +from . import filters as filters +from . import setters as setters +from . import validators as validators +from ._version_info import VersionInfo + + +__version__: str +__version_info__: VersionInfo +__title__: str +__description__: str +__url__: str +__uri__: str +__author__: str +__email__: str +__license__: str +__copyright__: str + +_T = TypeVar("_T") +_C = TypeVar("_C", bound=type) + +_EqOrderType = Union[bool, Callable[[Any], Any]] +_ValidatorType = Callable[[Any, Attribute[_T], _T], Any] +_ConverterType = Callable[[Any], Any] +_FilterType = Callable[[Attribute[_T], _T], bool] +_ReprType = Callable[[Any], str] +_ReprArgType = Union[bool, _ReprType] +_OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any] +_OnSetAttrArgType = Union[ + _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType +] +_FieldTransformer = Callable[[type, List[Attribute[Any]]], List[Attribute[Any]]] +# FIXME: in reality, if multiple validators are passed they must be in a list +# or tuple, but those are invariant and so would prevent subtypes of +# _ValidatorType from working when passed in a list or tuple. +_ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] + +# _make -- + +NOTHING: object + +# NOTE: Factory lies about its return type to make this possible: +# `x: List[int] # = Factory(list)` +# Work around mypy issue #4554 in the common case by using an overload. +if sys.version_info >= (3, 8): + from typing import Literal + + @overload + def Factory(factory: Callable[[], _T]) -> _T: ... + @overload + def Factory( + factory: Callable[[Any], _T], + takes_self: Literal[True], + ) -> _T: ... + @overload + def Factory( + factory: Callable[[], _T], + takes_self: Literal[False], + ) -> _T: ... +else: + @overload + def Factory(factory: Callable[[], _T]) -> _T: ... + @overload + def Factory( + factory: Union[Callable[[Any], _T], Callable[[], _T]], + takes_self: bool = ..., + ) -> _T: ... + +# Static type inference support via __dataclass_transform__ implemented as per: +# https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md +# This annotation must be applied to all overloads of "define" and "attrs" +# +# NOTE: This is a typing construct and does not exist at runtime. Extensions +# wrapping attrs decorators should declare a separate __dataclass_transform__ +# signature in the extension module using the specification linked above to +# provide pyright support. +def __dataclass_transform__( + *, + eq_default: bool = True, + order_default: bool = False, + kw_only_default: bool = False, + field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()), +) -> Callable[[_T], _T]: ... + +class Attribute(Generic[_T]): + name: str + default: Optional[_T] + validator: Optional[_ValidatorType[_T]] + repr: _ReprArgType + cmp: _EqOrderType + eq: _EqOrderType + order: _EqOrderType + hash: Optional[bool] + init: bool + converter: Optional[_ConverterType] + metadata: Dict[Any, Any] + type: Optional[Type[_T]] + kw_only: bool + on_setattr: _OnSetAttrType + + def evolve(self, **changes: Any) -> "Attribute[Any]": ... + +# NOTE: We had several choices for the annotation to use for type arg: +# 1) Type[_T] +# - Pros: Handles simple cases correctly +# - Cons: Might produce less informative errors in the case of conflicting +# TypeVars e.g. `attr.ib(default='bad', type=int)` +# 2) Callable[..., _T] +# - Pros: Better error messages than #1 for conflicting TypeVars +# - Cons: Terrible error messages for validator checks. +# e.g. attr.ib(type=int, validator=validate_str) +# -> error: Cannot infer function type argument +# 3) type (and do all of the work in the mypy plugin) +# - Pros: Simple here, and we could customize the plugin with our own errors. +# - Cons: Would need to write mypy plugin code to handle all the cases. +# We chose option #1. + +# `attr` lies about its return type to make the following possible: +# attr() -> Any +# attr(8) -> int +# attr(validator=) -> Whatever the callable expects. +# This makes this type of assignments possible: +# x: int = attr(8) +# +# This form catches explicit None or no default but with no other arguments +# returns Any. +@overload +def attrib( + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: None = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def attrib( + default: None = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: Optional[Type[_T]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def attrib( + default: _T, + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: Optional[Type[_T]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def attrib( + default: Optional[_T] = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: object = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... +@overload +def field( + *, + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def field( + *, + default: None = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def field( + *, + default: _T, + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def field( + *, + default: Optional[_T] = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... +@overload +@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) +def attrs( + maybe_cls: _C, + these: Optional[Dict[str, Any]] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + auto_detect: bool = ..., + collect_by_mro: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> _C: ... +@overload +@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) +def attrs( + maybe_cls: None = ..., + these: Optional[Dict[str, Any]] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + auto_detect: bool = ..., + collect_by_mro: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> Callable[[_C], _C]: ... +@overload +@__dataclass_transform__(field_descriptors=(attrib, field)) +def define( + maybe_cls: _C, + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> _C: ... +@overload +@__dataclass_transform__(field_descriptors=(attrib, field)) +def define( + maybe_cls: None = ..., + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> Callable[[_C], _C]: ... + +mutable = define +frozen = define # they differ only in their defaults + +# TODO: add support for returning NamedTuple from the mypy plugin +class _Fields(Tuple[Attribute[Any], ...]): + def __getattr__(self, name: str) -> Attribute[Any]: ... + +def fields(cls: type) -> _Fields: ... +def fields_dict(cls: type) -> Dict[str, Attribute[Any]]: ... +def validate(inst: Any) -> None: ... +def resolve_types( + cls: _C, + globalns: Optional[Dict[str, Any]] = ..., + localns: Optional[Dict[str, Any]] = ..., + attribs: Optional[List[Attribute[Any]]] = ..., +) -> _C: ... + +# TODO: add support for returning a proper attrs class from the mypy plugin +# we use Any instead of _CountingAttr so that e.g. `make_class('Foo', +# [attr.ib()])` is valid +def make_class( + name: str, + attrs: Union[List[str], Tuple[str, ...], Dict[str, Any]], + bases: Tuple[type, ...] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + collect_by_mro: bool = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> type: ... + +# _funcs -- + +# TODO: add support for returning TypedDict from the mypy plugin +# FIXME: asdict/astuple do not honor their factory args. Waiting on one of +# these: +# https://github.com/python/mypy/issues/4236 +# https://github.com/python/typing/issues/253 +def asdict( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType[Any]] = ..., + dict_factory: Type[Mapping[Any, Any]] = ..., + retain_collection_types: bool = ..., + value_serializer: Optional[Callable[[type, Attribute[Any], Any], Any]] = ..., +) -> Dict[str, Any]: ... + +# TODO: add support for returning NamedTuple from the mypy plugin +def astuple( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType[Any]] = ..., + tuple_factory: Type[Sequence[Any]] = ..., + retain_collection_types: bool = ..., +) -> Tuple[Any, ...]: ... +def has(cls: type) -> bool: ... +def assoc(inst: _T, **changes: Any) -> _T: ... +def evolve(inst: _T, **changes: Any) -> _T: ... + +# _config -- + +def set_run_validators(run: bool) -> None: ... +def get_run_validators() -> bool: ... + +# aliases -- + +s = attributes = attrs +ib = attr = attrib +dataclass = attrs # Technically, partial(attrs, auto_attribs=True) ;) diff --git a/openpype/hosts/fusion/vendor/attr/_cmp.py b/openpype/hosts/fusion/vendor/attr/_cmp.py new file mode 100644 index 0000000000..b747b603f1 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_cmp.py @@ -0,0 +1,152 @@ +from __future__ import absolute_import, division, print_function + +import functools + +from ._compat import new_class +from ._make import _make_ne + + +_operation_names = {"eq": "==", "lt": "<", "le": "<=", "gt": ">", "ge": ">="} + + +def cmp_using( + eq=None, + lt=None, + le=None, + gt=None, + ge=None, + require_same_type=True, + class_name="Comparable", +): + """ + Create a class that can be passed into `attr.ib`'s ``eq``, ``order``, and + ``cmp`` arguments to customize field comparison. + + The resulting class will have a full set of ordering methods if + at least one of ``{lt, le, gt, ge}`` and ``eq`` are provided. + + :param Optional[callable] eq: `callable` used to evaluate equality + of two objects. + :param Optional[callable] lt: `callable` used to evaluate whether + one object is less than another object. + :param Optional[callable] le: `callable` used to evaluate whether + one object is less than or equal to another object. + :param Optional[callable] gt: `callable` used to evaluate whether + one object is greater than another object. + :param Optional[callable] ge: `callable` used to evaluate whether + one object is greater than or equal to another object. + + :param bool require_same_type: When `True`, equality and ordering methods + will return `NotImplemented` if objects are not of the same type. + + :param Optional[str] class_name: Name of class. Defaults to 'Comparable'. + + See `comparison` for more details. + + .. versionadded:: 21.1.0 + """ + + body = { + "__slots__": ["value"], + "__init__": _make_init(), + "_requirements": [], + "_is_comparable_to": _is_comparable_to, + } + + # Add operations. + num_order_functions = 0 + has_eq_function = False + + if eq is not None: + has_eq_function = True + body["__eq__"] = _make_operator("eq", eq) + body["__ne__"] = _make_ne() + + if lt is not None: + num_order_functions += 1 + body["__lt__"] = _make_operator("lt", lt) + + if le is not None: + num_order_functions += 1 + body["__le__"] = _make_operator("le", le) + + if gt is not None: + num_order_functions += 1 + body["__gt__"] = _make_operator("gt", gt) + + if ge is not None: + num_order_functions += 1 + body["__ge__"] = _make_operator("ge", ge) + + type_ = new_class(class_name, (object,), {}, lambda ns: ns.update(body)) + + # Add same type requirement. + if require_same_type: + type_._requirements.append(_check_same_type) + + # Add total ordering if at least one operation was defined. + if 0 < num_order_functions < 4: + if not has_eq_function: + # functools.total_ordering requires __eq__ to be defined, + # so raise early error here to keep a nice stack. + raise ValueError( + "eq must be define is order to complete ordering from " + "lt, le, gt, ge." + ) + type_ = functools.total_ordering(type_) + + return type_ + + +def _make_init(): + """ + Create __init__ method. + """ + + def __init__(self, value): + """ + Initialize object with *value*. + """ + self.value = value + + return __init__ + + +def _make_operator(name, func): + """ + Create operator method. + """ + + def method(self, other): + if not self._is_comparable_to(other): + return NotImplemented + + result = func(self.value, other.value) + if result is NotImplemented: + return NotImplemented + + return result + + method.__name__ = "__%s__" % (name,) + method.__doc__ = "Return a %s b. Computed by attrs." % ( + _operation_names[name], + ) + + return method + + +def _is_comparable_to(self, other): + """ + Check whether `other` is comparable to `self`. + """ + for func in self._requirements: + if not func(self, other): + return False + return True + + +def _check_same_type(self, other): + """ + Return True if *self* and *other* are of the same type, False otherwise. + """ + return other.value.__class__ is self.value.__class__ diff --git a/openpype/hosts/fusion/vendor/attr/_cmp.pyi b/openpype/hosts/fusion/vendor/attr/_cmp.pyi new file mode 100644 index 0000000000..7093550f0f --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_cmp.pyi @@ -0,0 +1,14 @@ +from typing import Type + +from . import _CompareWithType + + +def cmp_using( + eq: Optional[_CompareWithType], + lt: Optional[_CompareWithType], + le: Optional[_CompareWithType], + gt: Optional[_CompareWithType], + ge: Optional[_CompareWithType], + require_same_type: bool, + class_name: str, +) -> Type: ... diff --git a/openpype/hosts/fusion/vendor/attr/_compat.py b/openpype/hosts/fusion/vendor/attr/_compat.py new file mode 100644 index 0000000000..6939f338da --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_compat.py @@ -0,0 +1,242 @@ +from __future__ import absolute_import, division, print_function + +import platform +import sys +import types +import warnings + + +PY2 = sys.version_info[0] == 2 +PYPY = platform.python_implementation() == "PyPy" + + +if PYPY or sys.version_info[:2] >= (3, 6): + ordered_dict = dict +else: + from collections import OrderedDict + + ordered_dict = OrderedDict + + +if PY2: + from collections import Mapping, Sequence + + from UserDict import IterableUserDict + + # We 'bundle' isclass instead of using inspect as importing inspect is + # fairly expensive (order of 10-15 ms for a modern machine in 2016) + def isclass(klass): + return isinstance(klass, (type, types.ClassType)) + + def new_class(name, bases, kwds, exec_body): + """ + A minimal stub of types.new_class that we need for make_class. + """ + ns = {} + exec_body(ns) + + return type(name, bases, ns) + + # TYPE is used in exceptions, repr(int) is different on Python 2 and 3. + TYPE = "type" + + def iteritems(d): + return d.iteritems() + + # Python 2 is bereft of a read-only dict proxy, so we make one! + class ReadOnlyDict(IterableUserDict): + """ + Best-effort read-only dict wrapper. + """ + + def __setitem__(self, key, val): + # We gently pretend we're a Python 3 mappingproxy. + raise TypeError( + "'mappingproxy' object does not support item assignment" + ) + + def update(self, _): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'update'" + ) + + def __delitem__(self, _): + # We gently pretend we're a Python 3 mappingproxy. + raise TypeError( + "'mappingproxy' object does not support item deletion" + ) + + def clear(self): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'clear'" + ) + + def pop(self, key, default=None): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'pop'" + ) + + def popitem(self): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'popitem'" + ) + + def setdefault(self, key, default=None): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'setdefault'" + ) + + def __repr__(self): + # Override to be identical to the Python 3 version. + return "mappingproxy(" + repr(self.data) + ")" + + def metadata_proxy(d): + res = ReadOnlyDict() + res.data.update(d) # We blocked update, so we have to do it like this. + return res + + def just_warn(*args, **kw): # pragma: no cover + """ + We only warn on Python 3 because we are not aware of any concrete + consequences of not setting the cell on Python 2. + """ + + +else: # Python 3 and later. + from collections.abc import Mapping, Sequence # noqa + + def just_warn(*args, **kw): + """ + We only warn on Python 3 because we are not aware of any concrete + consequences of not setting the cell on Python 2. + """ + warnings.warn( + "Running interpreter doesn't sufficiently support code object " + "introspection. Some features like bare super() or accessing " + "__class__ will not work with slotted classes.", + RuntimeWarning, + stacklevel=2, + ) + + def isclass(klass): + return isinstance(klass, type) + + TYPE = "class" + + def iteritems(d): + return d.items() + + new_class = types.new_class + + def metadata_proxy(d): + return types.MappingProxyType(dict(d)) + + +def make_set_closure_cell(): + """Return a function of two arguments (cell, value) which sets + the value stored in the closure cell `cell` to `value`. + """ + # pypy makes this easy. (It also supports the logic below, but + # why not do the easy/fast thing?) + if PYPY: + + def set_closure_cell(cell, value): + cell.__setstate__((value,)) + + return set_closure_cell + + # Otherwise gotta do it the hard way. + + # Create a function that will set its first cellvar to `value`. + def set_first_cellvar_to(value): + x = value + return + + # This function will be eliminated as dead code, but + # not before its reference to `x` forces `x` to be + # represented as a closure cell rather than a local. + def force_x_to_be_a_cell(): # pragma: no cover + return x + + try: + # Extract the code object and make sure our assumptions about + # the closure behavior are correct. + if PY2: + co = set_first_cellvar_to.func_code + else: + co = set_first_cellvar_to.__code__ + if co.co_cellvars != ("x",) or co.co_freevars != (): + raise AssertionError # pragma: no cover + + # Convert this code object to a code object that sets the + # function's first _freevar_ (not cellvar) to the argument. + if sys.version_info >= (3, 8): + # CPython 3.8+ has an incompatible CodeType signature + # (added a posonlyargcount argument) but also added + # CodeType.replace() to do this without counting parameters. + set_first_freevar_code = co.replace( + co_cellvars=co.co_freevars, co_freevars=co.co_cellvars + ) + else: + args = [co.co_argcount] + if not PY2: + args.append(co.co_kwonlyargcount) + args.extend( + [ + co.co_nlocals, + co.co_stacksize, + co.co_flags, + co.co_code, + co.co_consts, + co.co_names, + co.co_varnames, + co.co_filename, + co.co_name, + co.co_firstlineno, + co.co_lnotab, + # These two arguments are reversed: + co.co_cellvars, + co.co_freevars, + ] + ) + set_first_freevar_code = types.CodeType(*args) + + def set_closure_cell(cell, value): + # Create a function using the set_first_freevar_code, + # whose first closure cell is `cell`. Calling it will + # change the value of that cell. + setter = types.FunctionType( + set_first_freevar_code, {}, "setter", (), (cell,) + ) + # And call it to set the cell. + setter(value) + + # Make sure it works on this interpreter: + def make_func_with_cell(): + x = None + + def func(): + return x # pragma: no cover + + return func + + if PY2: + cell = make_func_with_cell().func_closure[0] + else: + cell = make_func_with_cell().__closure__[0] + set_closure_cell(cell, 100) + if cell.cell_contents != 100: + raise AssertionError # pragma: no cover + + except Exception: + return just_warn + else: + return set_closure_cell + + +set_closure_cell = make_set_closure_cell() diff --git a/openpype/hosts/fusion/vendor/attr/_config.py b/openpype/hosts/fusion/vendor/attr/_config.py new file mode 100644 index 0000000000..8ec920962d --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_config.py @@ -0,0 +1,23 @@ +from __future__ import absolute_import, division, print_function + + +__all__ = ["set_run_validators", "get_run_validators"] + +_run_validators = True + + +def set_run_validators(run): + """ + Set whether or not validators are run. By default, they are run. + """ + if not isinstance(run, bool): + raise TypeError("'run' must be bool.") + global _run_validators + _run_validators = run + + +def get_run_validators(): + """ + Return whether or not validators are run. + """ + return _run_validators diff --git a/openpype/hosts/fusion/vendor/attr/_funcs.py b/openpype/hosts/fusion/vendor/attr/_funcs.py new file mode 100644 index 0000000000..fda508c5c4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_funcs.py @@ -0,0 +1,395 @@ +from __future__ import absolute_import, division, print_function + +import copy + +from ._compat import iteritems +from ._make import NOTHING, _obj_setattr, fields +from .exceptions import AttrsAttributeNotFoundError + + +def asdict( + inst, + recurse=True, + filter=None, + dict_factory=dict, + retain_collection_types=False, + value_serializer=None, +): + """ + Return the ``attrs`` attribute values of *inst* as a dict. + + Optionally recurse into other ``attrs``-decorated classes. + + :param inst: Instance of an ``attrs``-decorated class. + :param bool recurse: Recurse into classes that are also + ``attrs``-decorated. + :param callable filter: A callable whose return code determines whether an + attribute or element is included (``True``) or dropped (``False``). Is + called with the `attr.Attribute` as the first argument and the + value as the second argument. + :param callable dict_factory: A callable to produce dictionaries from. For + example, to produce ordered dictionaries instead of normal Python + dictionaries, pass in ``collections.OrderedDict``. + :param bool retain_collection_types: Do not convert to ``list`` when + encountering an attribute whose type is ``tuple`` or ``set``. Only + meaningful if ``recurse`` is ``True``. + :param Optional[callable] value_serializer: A hook that is called for every + attribute or dict key/value. It receives the current instance, field + and value and must return the (updated) value. The hook is run *after* + the optional *filter* has been applied. + + :rtype: return type of *dict_factory* + + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 16.0.0 *dict_factory* + .. versionadded:: 16.1.0 *retain_collection_types* + .. versionadded:: 20.3.0 *value_serializer* + """ + attrs = fields(inst.__class__) + rv = dict_factory() + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + + if value_serializer is not None: + v = value_serializer(inst, a, v) + + if recurse is True: + if has(v.__class__): + rv[a.name] = asdict( + v, + True, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + elif isinstance(v, (tuple, list, set, frozenset)): + cf = v.__class__ if retain_collection_types is True else list + rv[a.name] = cf( + [ + _asdict_anything( + i, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + for i in v + ] + ) + elif isinstance(v, dict): + df = dict_factory + rv[a.name] = df( + ( + _asdict_anything( + kk, + filter, + df, + retain_collection_types, + value_serializer, + ), + _asdict_anything( + vv, + filter, + df, + retain_collection_types, + value_serializer, + ), + ) + for kk, vv in iteritems(v) + ) + else: + rv[a.name] = v + else: + rv[a.name] = v + return rv + + +def _asdict_anything( + val, + filter, + dict_factory, + retain_collection_types, + value_serializer, +): + """ + ``asdict`` only works on attrs instances, this works on anything. + """ + if getattr(val.__class__, "__attrs_attrs__", None) is not None: + # Attrs class. + rv = asdict( + val, + True, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + elif isinstance(val, (tuple, list, set, frozenset)): + cf = val.__class__ if retain_collection_types is True else list + rv = cf( + [ + _asdict_anything( + i, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + for i in val + ] + ) + elif isinstance(val, dict): + df = dict_factory + rv = df( + ( + _asdict_anything( + kk, filter, df, retain_collection_types, value_serializer + ), + _asdict_anything( + vv, filter, df, retain_collection_types, value_serializer + ), + ) + for kk, vv in iteritems(val) + ) + else: + rv = val + if value_serializer is not None: + rv = value_serializer(None, None, rv) + + return rv + + +def astuple( + inst, + recurse=True, + filter=None, + tuple_factory=tuple, + retain_collection_types=False, +): + """ + Return the ``attrs`` attribute values of *inst* as a tuple. + + Optionally recurse into other ``attrs``-decorated classes. + + :param inst: Instance of an ``attrs``-decorated class. + :param bool recurse: Recurse into classes that are also + ``attrs``-decorated. + :param callable filter: A callable whose return code determines whether an + attribute or element is included (``True``) or dropped (``False``). Is + called with the `attr.Attribute` as the first argument and the + value as the second argument. + :param callable tuple_factory: A callable to produce tuples from. For + example, to produce lists instead of tuples. + :param bool retain_collection_types: Do not convert to ``list`` + or ``dict`` when encountering an attribute which type is + ``tuple``, ``dict`` or ``set``. Only meaningful if ``recurse`` is + ``True``. + + :rtype: return type of *tuple_factory* + + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 16.2.0 + """ + attrs = fields(inst.__class__) + rv = [] + retain = retain_collection_types # Very long. :/ + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + if recurse is True: + if has(v.__class__): + rv.append( + astuple( + v, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + ) + elif isinstance(v, (tuple, list, set, frozenset)): + cf = v.__class__ if retain is True else list + rv.append( + cf( + [ + astuple( + j, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(j.__class__) + else j + for j in v + ] + ) + ) + elif isinstance(v, dict): + df = v.__class__ if retain is True else dict + rv.append( + df( + ( + astuple( + kk, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(kk.__class__) + else kk, + astuple( + vv, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(vv.__class__) + else vv, + ) + for kk, vv in iteritems(v) + ) + ) + else: + rv.append(v) + else: + rv.append(v) + + return rv if tuple_factory is list else tuple_factory(rv) + + +def has(cls): + """ + Check whether *cls* is a class with ``attrs`` attributes. + + :param type cls: Class to introspect. + :raise TypeError: If *cls* is not a class. + + :rtype: bool + """ + return getattr(cls, "__attrs_attrs__", None) is not None + + +def assoc(inst, **changes): + """ + Copy *inst* and apply *changes*. + + :param inst: Instance of a class with ``attrs`` attributes. + :param changes: Keyword changes in the new copy. + + :return: A copy of inst with *changes* incorporated. + + :raise attr.exceptions.AttrsAttributeNotFoundError: If *attr_name* couldn't + be found on *cls*. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. deprecated:: 17.1.0 + Use `evolve` instead. + """ + import warnings + + warnings.warn( + "assoc is deprecated and will be removed after 2018/01.", + DeprecationWarning, + stacklevel=2, + ) + new = copy.copy(inst) + attrs = fields(inst.__class__) + for k, v in iteritems(changes): + a = getattr(attrs, k, NOTHING) + if a is NOTHING: + raise AttrsAttributeNotFoundError( + "{k} is not an attrs attribute on {cl}.".format( + k=k, cl=new.__class__ + ) + ) + _obj_setattr(new, k, v) + return new + + +def evolve(inst, **changes): + """ + Create a new instance, based on *inst* with *changes* applied. + + :param inst: Instance of a class with ``attrs`` attributes. + :param changes: Keyword changes in the new copy. + + :return: A copy of inst with *changes* incorporated. + + :raise TypeError: If *attr_name* couldn't be found in the class + ``__init__``. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 17.1.0 + """ + cls = inst.__class__ + attrs = fields(cls) + for a in attrs: + if not a.init: + continue + attr_name = a.name # To deal with private attributes. + init_name = attr_name if attr_name[0] != "_" else attr_name[1:] + if init_name not in changes: + changes[init_name] = getattr(inst, attr_name) + + return cls(**changes) + + +def resolve_types(cls, globalns=None, localns=None, attribs=None): + """ + Resolve any strings and forward annotations in type annotations. + + This is only required if you need concrete types in `Attribute`'s *type* + field. In other words, you don't need to resolve your types if you only + use them for static type checking. + + With no arguments, names will be looked up in the module in which the class + was created. If this is not what you want, e.g. if the name only exists + inside a method, you may pass *globalns* or *localns* to specify other + dictionaries in which to look up these names. See the docs of + `typing.get_type_hints` for more details. + + :param type cls: Class to resolve. + :param Optional[dict] globalns: Dictionary containing global variables. + :param Optional[dict] localns: Dictionary containing local variables. + :param Optional[list] attribs: List of attribs for the given class. + This is necessary when calling from inside a ``field_transformer`` + since *cls* is not an ``attrs`` class yet. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class and you didn't pass any attribs. + :raise NameError: If types cannot be resolved because of missing variables. + + :returns: *cls* so you can use this function also as a class decorator. + Please note that you have to apply it **after** `attr.s`. That means + the decorator has to come in the line **before** `attr.s`. + + .. versionadded:: 20.1.0 + .. versionadded:: 21.1.0 *attribs* + + """ + try: + # Since calling get_type_hints is expensive we cache whether we've + # done it already. + cls.__attrs_types_resolved__ + except AttributeError: + import typing + + hints = typing.get_type_hints(cls, globalns=globalns, localns=localns) + for field in fields(cls) if attribs is None else attribs: + if field.name in hints: + # Since fields have been frozen we must work around it. + _obj_setattr(field, "type", hints[field.name]) + cls.__attrs_types_resolved__ = True + + # Return the class so you can use it as a decorator too. + return cls diff --git a/openpype/hosts/fusion/vendor/attr/_make.py b/openpype/hosts/fusion/vendor/attr/_make.py new file mode 100644 index 0000000000..a1912b1233 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_make.py @@ -0,0 +1,3052 @@ +from __future__ import absolute_import, division, print_function + +import copy +import inspect +import linecache +import sys +import threading +import uuid +import warnings + +from operator import itemgetter + +from . import _config, setters +from ._compat import ( + PY2, + PYPY, + isclass, + iteritems, + metadata_proxy, + new_class, + ordered_dict, + set_closure_cell, +) +from .exceptions import ( + DefaultAlreadySetError, + FrozenInstanceError, + NotAnAttrsClassError, + PythonTooOldError, + UnannotatedAttributeError, +) + + +if not PY2: + import typing + + +# This is used at least twice, so cache it here. +_obj_setattr = object.__setattr__ +_init_converter_pat = "__attr_converter_%s" +_init_factory_pat = "__attr_factory_{}" +_tuple_property_pat = ( + " {attr_name} = _attrs_property(_attrs_itemgetter({index}))" +) +_classvar_prefixes = ( + "typing.ClassVar", + "t.ClassVar", + "ClassVar", + "typing_extensions.ClassVar", +) +# we don't use a double-underscore prefix because that triggers +# name mangling when trying to create a slot for the field +# (when slots=True) +_hash_cache_field = "_attrs_cached_hash" + +_empty_metadata_singleton = metadata_proxy({}) + +# Unique object for unequivocal getattr() defaults. +_sentinel = object() + + +class _Nothing(object): + """ + Sentinel class to indicate the lack of a value when ``None`` is ambiguous. + + ``_Nothing`` is a singleton. There is only ever one of it. + + .. versionchanged:: 21.1.0 ``bool(NOTHING)`` is now False. + """ + + _singleton = None + + def __new__(cls): + if _Nothing._singleton is None: + _Nothing._singleton = super(_Nothing, cls).__new__(cls) + return _Nothing._singleton + + def __repr__(self): + return "NOTHING" + + def __bool__(self): + return False + + def __len__(self): + return 0 # __bool__ for Python 2 + + +NOTHING = _Nothing() +""" +Sentinel to indicate the lack of a value when ``None`` is ambiguous. +""" + + +class _CacheHashWrapper(int): + """ + An integer subclass that pickles / copies as None + + This is used for non-slots classes with ``cache_hash=True``, to avoid + serializing a potentially (even likely) invalid hash value. Since ``None`` + is the default value for uncalculated hashes, whenever this is copied, + the copy's value for the hash should automatically reset. + + See GH #613 for more details. + """ + + if PY2: + # For some reason `type(None)` isn't callable in Python 2, but we don't + # actually need a constructor for None objects, we just need any + # available function that returns None. + def __reduce__(self, _none_constructor=getattr, _args=(0, "", None)): + return _none_constructor, _args + + else: + + def __reduce__(self, _none_constructor=type(None), _args=()): + return _none_constructor, _args + + +def attrib( + default=NOTHING, + validator=None, + repr=True, + cmp=None, + hash=None, + init=True, + metadata=None, + type=None, + converter=None, + factory=None, + kw_only=False, + eq=None, + order=None, + on_setattr=None, +): + """ + Create a new attribute on a class. + + .. warning:: + + Does *not* do anything unless the class is also decorated with + `attr.s`! + + :param default: A value that is used if an ``attrs``-generated ``__init__`` + is used and no value is passed while instantiating or the attribute is + excluded using ``init=False``. + + If the value is an instance of `Factory`, its callable will be + used to construct a new value (useful for mutable data types like lists + or dicts). + + If a default is not set (or set manually to `attr.NOTHING`), a value + *must* be supplied when instantiating; otherwise a `TypeError` + will be raised. + + The default can also be set using decorator notation as shown below. + + :type default: Any value + + :param callable factory: Syntactic sugar for + ``default=attr.Factory(factory)``. + + :param validator: `callable` that is called by ``attrs``-generated + ``__init__`` methods after the instance has been initialized. They + receive the initialized instance, the `Attribute`, and the + passed value. + + The return value is *not* inspected so the validator has to throw an + exception itself. + + If a `list` is passed, its items are treated as validators and must + all pass. + + Validators can be globally disabled and re-enabled using + `get_run_validators`. + + The validator can also be set using decorator notation as shown below. + + :type validator: `callable` or a `list` of `callable`\\ s. + + :param repr: Include this attribute in the generated ``__repr__`` + method. If ``True``, include the attribute; if ``False``, omit it. By + default, the built-in ``repr()`` function is used. To override how the + attribute value is formatted, pass a ``callable`` that takes a single + value and returns a string. Note that the resulting string is used + as-is, i.e. it will be used directly *instead* of calling ``repr()`` + (the default). + :type repr: a `bool` or a `callable` to use a custom function. + + :param eq: If ``True`` (default), include this attribute in the + generated ``__eq__`` and ``__ne__`` methods that check two instances + for equality. To override how the attribute value is compared, + pass a ``callable`` that takes a single value and returns the value + to be compared. + :type eq: a `bool` or a `callable`. + + :param order: If ``True`` (default), include this attributes in the + generated ``__lt__``, ``__le__``, ``__gt__`` and ``__ge__`` methods. + To override how the attribute value is ordered, + pass a ``callable`` that takes a single value and returns the value + to be ordered. + :type order: a `bool` or a `callable`. + + :param cmp: Setting *cmp* is equivalent to setting *eq* and *order* to the + same value. Must not be mixed with *eq* or *order*. + :type cmp: a `bool` or a `callable`. + + :param Optional[bool] hash: Include this attribute in the generated + ``__hash__`` method. If ``None`` (default), mirror *eq*'s value. This + is the correct behavior according the Python spec. Setting this value + to anything else than ``None`` is *discouraged*. + :param bool init: Include this attribute in the generated ``__init__`` + method. It is possible to set this to ``False`` and set a default + value. In that case this attributed is unconditionally initialized + with the specified default value or factory. + :param callable converter: `callable` that is called by + ``attrs``-generated ``__init__`` methods to convert attribute's value + to the desired format. It is given the passed-in value, and the + returned value will be used as the new value of the attribute. The + value is converted before being passed to the validator, if any. + :param metadata: An arbitrary mapping, to be used by third-party + components. See `extending_metadata`. + :param type: The type of the attribute. In Python 3.6 or greater, the + preferred method to specify the type is using a variable annotation + (see `PEP 526 `_). + This argument is provided for backward compatibility. + Regardless of the approach used, the type will be stored on + ``Attribute.type``. + + Please note that ``attrs`` doesn't do anything with this metadata by + itself. You can use it as part of your own code or for + `static type checking `. + :param kw_only: Make this attribute keyword-only (Python 3+) + in the generated ``__init__`` (if ``init`` is ``False``, this + parameter is ignored). + :param on_setattr: Allows to overwrite the *on_setattr* setting from + `attr.s`. If left `None`, the *on_setattr* value from `attr.s` is used. + Set to `attr.setters.NO_OP` to run **no** `setattr` hooks for this + attribute -- regardless of the setting in `attr.s`. + :type on_setattr: `callable`, or a list of callables, or `None`, or + `attr.setters.NO_OP` + + .. versionadded:: 15.2.0 *convert* + .. versionadded:: 16.3.0 *metadata* + .. versionchanged:: 17.1.0 *validator* can be a ``list`` now. + .. versionchanged:: 17.1.0 + *hash* is ``None`` and therefore mirrors *eq* by default. + .. versionadded:: 17.3.0 *type* + .. deprecated:: 17.4.0 *convert* + .. versionadded:: 17.4.0 *converter* as a replacement for the deprecated + *convert* to achieve consistency with other noun-based arguments. + .. versionadded:: 18.1.0 + ``factory=f`` is syntactic sugar for ``default=attr.Factory(f)``. + .. versionadded:: 18.2.0 *kw_only* + .. versionchanged:: 19.2.0 *convert* keyword argument removed. + .. versionchanged:: 19.2.0 *repr* also accepts a custom callable. + .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. + .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.3.0 *kw_only* backported to Python 2 + .. versionchanged:: 21.1.0 + *eq*, *order*, and *cmp* also accept a custom callable + .. versionchanged:: 21.1.0 *cmp* undeprecated + """ + eq, eq_key, order, order_key = _determine_attrib_eq_order( + cmp, eq, order, True + ) + + if hash is not None and hash is not True and hash is not False: + raise TypeError( + "Invalid value for hash. Must be True, False, or None." + ) + + if factory is not None: + if default is not NOTHING: + raise ValueError( + "The `default` and `factory` arguments are mutually " + "exclusive." + ) + if not callable(factory): + raise ValueError("The `factory` argument must be a callable.") + default = Factory(factory) + + if metadata is None: + metadata = {} + + # Apply syntactic sugar by auto-wrapping. + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + if validator and isinstance(validator, (list, tuple)): + validator = and_(*validator) + + if converter and isinstance(converter, (list, tuple)): + converter = pipe(*converter) + + return _CountingAttr( + default=default, + validator=validator, + repr=repr, + cmp=None, + hash=hash, + init=init, + converter=converter, + metadata=metadata, + type=type, + kw_only=kw_only, + eq=eq, + eq_key=eq_key, + order=order, + order_key=order_key, + on_setattr=on_setattr, + ) + + +def _compile_and_eval(script, globs, locs=None, filename=""): + """ + "Exec" the script with the given global (globs) and local (locs) variables. + """ + bytecode = compile(script, filename, "exec") + eval(bytecode, globs, locs) + + +def _make_method(name, script, filename, globs=None): + """ + Create the method with the script given and return the method object. + """ + locs = {} + if globs is None: + globs = {} + + _compile_and_eval(script, globs, locs, filename) + + # In order of debuggers like PDB being able to step through the code, + # we add a fake linecache entry. + linecache.cache[filename] = ( + len(script), + None, + script.splitlines(True), + filename, + ) + + return locs[name] + + +def _make_attr_tuple_class(cls_name, attr_names): + """ + Create a tuple subclass to hold `Attribute`s for an `attrs` class. + + The subclass is a bare tuple with properties for names. + + class MyClassAttributes(tuple): + __slots__ = () + x = property(itemgetter(0)) + """ + attr_class_name = "{}Attributes".format(cls_name) + attr_class_template = [ + "class {}(tuple):".format(attr_class_name), + " __slots__ = ()", + ] + if attr_names: + for i, attr_name in enumerate(attr_names): + attr_class_template.append( + _tuple_property_pat.format(index=i, attr_name=attr_name) + ) + else: + attr_class_template.append(" pass") + globs = {"_attrs_itemgetter": itemgetter, "_attrs_property": property} + _compile_and_eval("\n".join(attr_class_template), globs) + return globs[attr_class_name] + + +# Tuple class for extracted attributes from a class definition. +# `base_attrs` is a subset of `attrs`. +_Attributes = _make_attr_tuple_class( + "_Attributes", + [ + # all attributes to build dunder methods for + "attrs", + # attributes that have been inherited + "base_attrs", + # map inherited attributes to their originating classes + "base_attrs_map", + ], +) + + +def _is_class_var(annot): + """ + Check whether *annot* is a typing.ClassVar. + + The string comparison hack is used to avoid evaluating all string + annotations which would put attrs-based classes at a performance + disadvantage compared to plain old classes. + """ + annot = str(annot) + + # Annotation can be quoted. + if annot.startswith(("'", '"')) and annot.endswith(("'", '"')): + annot = annot[1:-1] + + return annot.startswith(_classvar_prefixes) + + +def _has_own_attribute(cls, attrib_name): + """ + Check whether *cls* defines *attrib_name* (and doesn't just inherit it). + + Requires Python 3. + """ + attr = getattr(cls, attrib_name, _sentinel) + if attr is _sentinel: + return False + + for base_cls in cls.__mro__[1:]: + a = getattr(base_cls, attrib_name, None) + if attr is a: + return False + + return True + + +def _get_annotations(cls): + """ + Get annotations for *cls*. + """ + if _has_own_attribute(cls, "__annotations__"): + return cls.__annotations__ + + return {} + + +def _counter_getter(e): + """ + Key function for sorting to avoid re-creating a lambda for every class. + """ + return e[1].counter + + +def _collect_base_attrs(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in reversed(cls.__mro__[1:-1]): + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.inherited or a.name in taken_attr_names: + continue + + a = a.evolve(inherited=True) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + # For each name, only keep the freshest definition i.e. the furthest at the + # back. base_attr_map is fine because it gets overwritten with every new + # instance. + filtered = [] + seen = set() + for a in reversed(base_attrs): + if a.name in seen: + continue + filtered.insert(0, a) + seen.add(a.name) + + return filtered, base_attr_map + + +def _collect_base_attrs_broken(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + + N.B. *taken_attr_names* will be mutated. + + Adhere to the old incorrect behavior. + + Notably it collects from the front and considers inherited attributes which + leads to the buggy behavior reported in #428. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in cls.__mro__[1:-1]: + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.name in taken_attr_names: + continue + + a = a.evolve(inherited=True) + taken_attr_names.add(a.name) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + return base_attrs, base_attr_map + + +def _transform_attrs( + cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer +): + """ + Transform all `_CountingAttr`s on a class into `Attribute`s. + + If *these* is passed, use that and don't look for them on the class. + + *collect_by_mro* is True, collect them in the correct MRO order, otherwise + use the old -- incorrect -- order. See #428. + + Return an `_Attributes`. + """ + cd = cls.__dict__ + anns = _get_annotations(cls) + + if these is not None: + ca_list = [(name, ca) for name, ca in iteritems(these)] + + if not isinstance(these, ordered_dict): + ca_list.sort(key=_counter_getter) + elif auto_attribs is True: + ca_names = { + name + for name, attr in cd.items() + if isinstance(attr, _CountingAttr) + } + ca_list = [] + annot_names = set() + for attr_name, type in anns.items(): + if _is_class_var(type): + continue + annot_names.add(attr_name) + a = cd.get(attr_name, NOTHING) + + if not isinstance(a, _CountingAttr): + if a is NOTHING: + a = attrib() + else: + a = attrib(default=a) + ca_list.append((attr_name, a)) + + unannotated = ca_names - annot_names + if len(unannotated) > 0: + raise UnannotatedAttributeError( + "The following `attr.ib`s lack a type annotation: " + + ", ".join( + sorted(unannotated, key=lambda n: cd.get(n).counter) + ) + + "." + ) + else: + ca_list = sorted( + ( + (name, attr) + for name, attr in cd.items() + if isinstance(attr, _CountingAttr) + ), + key=lambda e: e[1].counter, + ) + + own_attrs = [ + Attribute.from_counting_attr( + name=attr_name, ca=ca, type=anns.get(attr_name) + ) + for attr_name, ca in ca_list + ] + + if collect_by_mro: + base_attrs, base_attr_map = _collect_base_attrs( + cls, {a.name for a in own_attrs} + ) + else: + base_attrs, base_attr_map = _collect_base_attrs_broken( + cls, {a.name for a in own_attrs} + ) + + attr_names = [a.name for a in base_attrs + own_attrs] + + AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names) + + if kw_only: + own_attrs = [a.evolve(kw_only=True) for a in own_attrs] + base_attrs = [a.evolve(kw_only=True) for a in base_attrs] + + attrs = AttrsClass(base_attrs + own_attrs) + + # Mandatory vs non-mandatory attr order only matters when they are part of + # the __init__ signature and when they aren't kw_only (which are moved to + # the end and can be mandatory or non-mandatory in any order, as they will + # be specified as keyword args anyway). Check the order of those attrs: + had_default = False + for a in (a for a in attrs if a.init is not False and a.kw_only is False): + if had_default is True and a.default is NOTHING: + raise ValueError( + "No mandatory attributes allowed after an attribute with a " + "default value or factory. Attribute in question: %r" % (a,) + ) + + if had_default is False and a.default is not NOTHING: + had_default = True + + if field_transformer is not None: + attrs = field_transformer(cls, attrs) + return _Attributes((attrs, base_attrs, base_attr_map)) + + +if PYPY: + + def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + if isinstance(self, BaseException) and name in ( + "__cause__", + "__context__", + ): + BaseException.__setattr__(self, name, value) + return + + raise FrozenInstanceError() + + +else: + + def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + raise FrozenInstanceError() + + +def _frozen_delattrs(self, name): + """ + Attached to frozen classes as __delattr__. + """ + raise FrozenInstanceError() + + +class _ClassBuilder(object): + """ + Iteratively build *one* class. + """ + + __slots__ = ( + "_attr_names", + "_attrs", + "_base_attr_map", + "_base_names", + "_cache_hash", + "_cls", + "_cls_dict", + "_delete_attribs", + "_frozen", + "_has_pre_init", + "_has_post_init", + "_is_exc", + "_on_setattr", + "_slots", + "_weakref_slot", + "_has_own_setattr", + "_has_custom_setattr", + ) + + def __init__( + self, + cls, + these, + slots, + frozen, + weakref_slot, + getstate_setstate, + auto_attribs, + kw_only, + cache_hash, + is_exc, + collect_by_mro, + on_setattr, + has_custom_setattr, + field_transformer, + ): + attrs, base_attrs, base_map = _transform_attrs( + cls, + these, + auto_attribs, + kw_only, + collect_by_mro, + field_transformer, + ) + + self._cls = cls + self._cls_dict = dict(cls.__dict__) if slots else {} + self._attrs = attrs + self._base_names = set(a.name for a in base_attrs) + self._base_attr_map = base_map + self._attr_names = tuple(a.name for a in attrs) + self._slots = slots + self._frozen = frozen + self._weakref_slot = weakref_slot + self._cache_hash = cache_hash + self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) + self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) + self._delete_attribs = not bool(these) + self._is_exc = is_exc + self._on_setattr = on_setattr + + self._has_custom_setattr = has_custom_setattr + self._has_own_setattr = False + + self._cls_dict["__attrs_attrs__"] = self._attrs + + if frozen: + self._cls_dict["__setattr__"] = _frozen_setattrs + self._cls_dict["__delattr__"] = _frozen_delattrs + + self._has_own_setattr = True + + if getstate_setstate: + ( + self._cls_dict["__getstate__"], + self._cls_dict["__setstate__"], + ) = self._make_getstate_setstate() + + def __repr__(self): + return "<_ClassBuilder(cls={cls})>".format(cls=self._cls.__name__) + + def build_class(self): + """ + Finalize class based on the accumulated configuration. + + Builder cannot be used after calling this method. + """ + if self._slots is True: + return self._create_slots_class() + else: + return self._patch_original_class() + + def _patch_original_class(self): + """ + Apply accumulated methods and return the class. + """ + cls = self._cls + base_names = self._base_names + + # Clean class of attribute definitions (`attr.ib()`s). + if self._delete_attribs: + for name in self._attr_names: + if ( + name not in base_names + and getattr(cls, name, _sentinel) is not _sentinel + ): + try: + delattr(cls, name) + except AttributeError: + # This can happen if a base class defines a class + # variable and we want to set an attribute with the + # same name by using only a type annotation. + pass + + # Attach our dunder methods. + for name, value in self._cls_dict.items(): + setattr(cls, name, value) + + # If we've inherited an attrs __setattr__ and don't write our own, + # reset it to object's. + if not self._has_own_setattr and getattr( + cls, "__attrs_own_setattr__", False + ): + cls.__attrs_own_setattr__ = False + + if not self._has_custom_setattr: + cls.__setattr__ = object.__setattr__ + + return cls + + def _create_slots_class(self): + """ + Build and return a new class with a `__slots__` attribute. + """ + cd = { + k: v + for k, v in iteritems(self._cls_dict) + if k not in tuple(self._attr_names) + ("__dict__", "__weakref__") + } + + # If our class doesn't have its own implementation of __setattr__ + # (either from the user or by us), check the bases, if one of them has + # an attrs-made __setattr__, that needs to be reset. We don't walk the + # MRO because we only care about our immediate base classes. + # XXX: This can be confused by subclassing a slotted attrs class with + # XXX: a non-attrs class and subclass the resulting class with an attrs + # XXX: class. See `test_slotted_confused` for details. For now that's + # XXX: OK with us. + if not self._has_own_setattr: + cd["__attrs_own_setattr__"] = False + + if not self._has_custom_setattr: + for base_cls in self._cls.__bases__: + if base_cls.__dict__.get("__attrs_own_setattr__", False): + cd["__setattr__"] = object.__setattr__ + break + + # Traverse the MRO to collect existing slots + # and check for an existing __weakref__. + existing_slots = dict() + weakref_inherited = False + for base_cls in self._cls.__mro__[1:-1]: + if base_cls.__dict__.get("__weakref__", None) is not None: + weakref_inherited = True + existing_slots.update( + { + name: getattr(base_cls, name) + for name in getattr(base_cls, "__slots__", []) + } + ) + + base_names = set(self._base_names) + + names = self._attr_names + if ( + self._weakref_slot + and "__weakref__" not in getattr(self._cls, "__slots__", ()) + and "__weakref__" not in names + and not weakref_inherited + ): + names += ("__weakref__",) + + # We only add the names of attributes that aren't inherited. + # Setting __slots__ to inherited attributes wastes memory. + slot_names = [name for name in names if name not in base_names] + # There are slots for attributes from current class + # that are defined in parent classes. + # As their descriptors may be overriden by a child class, + # we collect them here and update the class dict + reused_slots = { + slot: slot_descriptor + for slot, slot_descriptor in iteritems(existing_slots) + if slot in slot_names + } + slot_names = [name for name in slot_names if name not in reused_slots] + cd.update(reused_slots) + if self._cache_hash: + slot_names.append(_hash_cache_field) + cd["__slots__"] = tuple(slot_names) + + qualname = getattr(self._cls, "__qualname__", None) + if qualname is not None: + cd["__qualname__"] = qualname + + # Create new class based on old class and our methods. + cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) + + # The following is a fix for + # https://github.com/python-attrs/attrs/issues/102. On Python 3, + # if a method mentions `__class__` or uses the no-arg super(), the + # compiler will bake a reference to the class in the method itself + # as `method.__closure__`. Since we replace the class with a + # clone, we rewrite these references so it keeps working. + for item in cls.__dict__.values(): + if isinstance(item, (classmethod, staticmethod)): + # Class- and staticmethods hide their functions inside. + # These might need to be rewritten as well. + closure_cells = getattr(item.__func__, "__closure__", None) + elif isinstance(item, property): + # Workaround for property `super()` shortcut (PY3-only). + # There is no universal way for other descriptors. + closure_cells = getattr(item.fget, "__closure__", None) + else: + closure_cells = getattr(item, "__closure__", None) + + if not closure_cells: # Catch None or the empty list. + continue + for cell in closure_cells: + try: + match = cell.cell_contents is self._cls + except ValueError: # ValueError: Cell is empty + pass + else: + if match: + set_closure_cell(cell, cls) + + return cls + + def add_repr(self, ns): + self._cls_dict["__repr__"] = self._add_method_dunders( + _make_repr(self._attrs, ns=ns) + ) + return self + + def add_str(self): + repr = self._cls_dict.get("__repr__") + if repr is None: + raise ValueError( + "__str__ can only be generated if a __repr__ exists." + ) + + def __str__(self): + return self.__repr__() + + self._cls_dict["__str__"] = self._add_method_dunders(__str__) + return self + + def _make_getstate_setstate(self): + """ + Create custom __setstate__ and __getstate__ methods. + """ + # __weakref__ is not writable. + state_attr_names = tuple( + an for an in self._attr_names if an != "__weakref__" + ) + + def slots_getstate(self): + """ + Automatically created by attrs. + """ + return tuple(getattr(self, name) for name in state_attr_names) + + hash_caching_enabled = self._cache_hash + + def slots_setstate(self, state): + """ + Automatically created by attrs. + """ + __bound_setattr = _obj_setattr.__get__(self, Attribute) + for name, value in zip(state_attr_names, state): + __bound_setattr(name, value) + + # The hash code cache is not included when the object is + # serialized, but it still needs to be initialized to None to + # indicate that the first call to __hash__ should be a cache + # miss. + if hash_caching_enabled: + __bound_setattr(_hash_cache_field, None) + + return slots_getstate, slots_setstate + + def make_unhashable(self): + self._cls_dict["__hash__"] = None + return self + + def add_hash(self): + self._cls_dict["__hash__"] = self._add_method_dunders( + _make_hash( + self._cls, + self._attrs, + frozen=self._frozen, + cache_hash=self._cache_hash, + ) + ) + + return self + + def add_init(self): + self._cls_dict["__init__"] = self._add_method_dunders( + _make_init( + self._cls, + self._attrs, + self._has_pre_init, + self._has_post_init, + self._frozen, + self._slots, + self._cache_hash, + self._base_attr_map, + self._is_exc, + self._on_setattr is not None + and self._on_setattr is not setters.NO_OP, + attrs_init=False, + ) + ) + + return self + + def add_attrs_init(self): + self._cls_dict["__attrs_init__"] = self._add_method_dunders( + _make_init( + self._cls, + self._attrs, + self._has_pre_init, + self._has_post_init, + self._frozen, + self._slots, + self._cache_hash, + self._base_attr_map, + self._is_exc, + self._on_setattr is not None + and self._on_setattr is not setters.NO_OP, + attrs_init=True, + ) + ) + + return self + + def add_eq(self): + cd = self._cls_dict + + cd["__eq__"] = self._add_method_dunders( + _make_eq(self._cls, self._attrs) + ) + cd["__ne__"] = self._add_method_dunders(_make_ne()) + + return self + + def add_order(self): + cd = self._cls_dict + + cd["__lt__"], cd["__le__"], cd["__gt__"], cd["__ge__"] = ( + self._add_method_dunders(meth) + for meth in _make_order(self._cls, self._attrs) + ) + + return self + + def add_setattr(self): + if self._frozen: + return self + + sa_attrs = {} + for a in self._attrs: + on_setattr = a.on_setattr or self._on_setattr + if on_setattr and on_setattr is not setters.NO_OP: + sa_attrs[a.name] = a, on_setattr + + if not sa_attrs: + return self + + if self._has_custom_setattr: + # We need to write a __setattr__ but there already is one! + raise ValueError( + "Can't combine custom __setattr__ with on_setattr hooks." + ) + + # docstring comes from _add_method_dunders + def __setattr__(self, name, val): + try: + a, hook = sa_attrs[name] + except KeyError: + nval = val + else: + nval = hook(self, a, val) + + _obj_setattr(self, name, nval) + + self._cls_dict["__attrs_own_setattr__"] = True + self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) + self._has_own_setattr = True + + return self + + def _add_method_dunders(self, method): + """ + Add __module__ and __qualname__ to a *method* if possible. + """ + try: + method.__module__ = self._cls.__module__ + except AttributeError: + pass + + try: + method.__qualname__ = ".".join( + (self._cls.__qualname__, method.__name__) + ) + except AttributeError: + pass + + try: + method.__doc__ = "Method generated by attrs for class %s." % ( + self._cls.__qualname__, + ) + except AttributeError: + pass + + return method + + +_CMP_DEPRECATION = ( + "The usage of `cmp` is deprecated and will be removed on or after " + "2021-06-01. Please use `eq` and `order` instead." +) + + +def _determine_attrs_eq_order(cmp, eq, order, default_eq): + """ + Validate the combination of *cmp*, *eq*, and *order*. Derive the effective + values of eq and order. If *eq* is None, set it to *default_eq*. + """ + if cmp is not None and any((eq is not None, order is not None)): + raise ValueError("Don't mix `cmp` with `eq' and `order`.") + + # cmp takes precedence due to bw-compatibility. + if cmp is not None: + return cmp, cmp + + # If left None, equality is set to the specified default and ordering + # mirrors equality. + if eq is None: + eq = default_eq + + if order is None: + order = eq + + if eq is False and order is True: + raise ValueError("`order` can only be True if `eq` is True too.") + + return eq, order + + +def _determine_attrib_eq_order(cmp, eq, order, default_eq): + """ + Validate the combination of *cmp*, *eq*, and *order*. Derive the effective + values of eq and order. If *eq* is None, set it to *default_eq*. + """ + if cmp is not None and any((eq is not None, order is not None)): + raise ValueError("Don't mix `cmp` with `eq' and `order`.") + + def decide_callable_or_boolean(value): + """ + Decide whether a key function is used. + """ + if callable(value): + value, key = True, value + else: + key = None + return value, key + + # cmp takes precedence due to bw-compatibility. + if cmp is not None: + cmp, cmp_key = decide_callable_or_boolean(cmp) + return cmp, cmp_key, cmp, cmp_key + + # If left None, equality is set to the specified default and ordering + # mirrors equality. + if eq is None: + eq, eq_key = default_eq, None + else: + eq, eq_key = decide_callable_or_boolean(eq) + + if order is None: + order, order_key = eq, eq_key + else: + order, order_key = decide_callable_or_boolean(order) + + if eq is False and order is True: + raise ValueError("`order` can only be True if `eq` is True too.") + + return eq, eq_key, order, order_key + + +def _determine_whether_to_implement( + cls, flag, auto_detect, dunders, default=True +): + """ + Check whether we should implement a set of methods for *cls*. + + *flag* is the argument passed into @attr.s like 'init', *auto_detect* the + same as passed into @attr.s and *dunders* is a tuple of attribute names + whose presence signal that the user has implemented it themselves. + + Return *default* if no reason for either for or against is found. + + auto_detect must be False on Python 2. + """ + if flag is True or flag is False: + return flag + + if flag is None and auto_detect is False: + return default + + # Logically, flag is None and auto_detect is True here. + for dunder in dunders: + if _has_own_attribute(cls, dunder): + return False + + return default + + +def attrs( + maybe_cls=None, + these=None, + repr_ns=None, + repr=None, + cmp=None, + hash=None, + init=None, + slots=False, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=False, + kw_only=False, + cache_hash=False, + auto_exc=False, + eq=None, + order=None, + auto_detect=False, + collect_by_mro=False, + getstate_setstate=None, + on_setattr=None, + field_transformer=None, +): + r""" + A class decorator that adds `dunder + `_\ -methods according to the + specified attributes using `attr.ib` or the *these* argument. + + :param these: A dictionary of name to `attr.ib` mappings. This is + useful to avoid the definition of your attributes within the class body + because you can't (e.g. if you want to add ``__repr__`` methods to + Django models) or don't want to. + + If *these* is not ``None``, ``attrs`` will *not* search the class body + for attributes and will *not* remove any attributes from it. + + If *these* is an ordered dict (`dict` on Python 3.6+, + `collections.OrderedDict` otherwise), the order is deduced from + the order of the attributes inside *these*. Otherwise the order + of the definition of the attributes is used. + + :type these: `dict` of `str` to `attr.ib` + + :param str repr_ns: When using nested classes, there's no way in Python 2 + to automatically detect that. Therefore it's possible to set the + namespace explicitly for a more meaningful ``repr`` output. + :param bool auto_detect: Instead of setting the *init*, *repr*, *eq*, + *order*, and *hash* arguments explicitly, assume they are set to + ``True`` **unless any** of the involved methods for one of the + arguments is implemented in the *current* class (i.e. it is *not* + inherited from some base class). + + So for example by implementing ``__eq__`` on a class yourself, + ``attrs`` will deduce ``eq=False`` and will create *neither* + ``__eq__`` *nor* ``__ne__`` (but Python classes come with a sensible + ``__ne__`` by default, so it *should* be enough to only implement + ``__eq__`` in most cases). + + .. warning:: + + If you prevent ``attrs`` from creating the ordering methods for you + (``order=False``, e.g. by implementing ``__le__``), it becomes + *your* responsibility to make sure its ordering is sound. The best + way is to use the `functools.total_ordering` decorator. + + + Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*, + *cmp*, or *hash* overrides whatever *auto_detect* would determine. + + *auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises + a `PythonTooOldError`. + + :param bool repr: Create a ``__repr__`` method with a human readable + representation of ``attrs`` attributes.. + :param bool str: Create a ``__str__`` method that is identical to + ``__repr__``. This is usually not necessary except for + `Exception`\ s. + :param Optional[bool] eq: If ``True`` or ``None`` (default), add ``__eq__`` + and ``__ne__`` methods that check two instances for equality. + + They compare the instances as if they were tuples of their ``attrs`` + attributes if and only if the types of both classes are *identical*! + :param Optional[bool] order: If ``True``, add ``__lt__``, ``__le__``, + ``__gt__``, and ``__ge__`` methods that behave like *eq* above and + allow instances to be ordered. If ``None`` (default) mirror value of + *eq*. + :param Optional[bool] cmp: Setting *cmp* is equivalent to setting *eq* + and *order* to the same value. Must not be mixed with *eq* or *order*. + :param Optional[bool] hash: If ``None`` (default), the ``__hash__`` method + is generated according how *eq* and *frozen* are set. + + 1. If *both* are True, ``attrs`` will generate a ``__hash__`` for you. + 2. If *eq* is True and *frozen* is False, ``__hash__`` will be set to + None, marking it unhashable (which it is). + 3. If *eq* is False, ``__hash__`` will be left untouched meaning the + ``__hash__`` method of the base class will be used (if base class is + ``object``, this means it will fall back to id-based hashing.). + + Although not recommended, you can decide for yourself and force + ``attrs`` to create one (e.g. if the class is immutable even though you + didn't freeze it programmatically) by passing ``True`` or not. Both of + these cases are rather special and should be used carefully. + + See our documentation on `hashing`, Python's documentation on + `object.__hash__`, and the `GitHub issue that led to the default \ + behavior `_ for more + details. + :param bool init: Create a ``__init__`` method that initializes the + ``attrs`` attributes. Leading underscores are stripped for the argument + name. If a ``__attrs_pre_init__`` method exists on the class, it will + be called before the class is initialized. If a ``__attrs_post_init__`` + method exists on the class, it will be called after the class is fully + initialized. + + If ``init`` is ``False``, an ``__attrs_init__`` method will be + injected instead. This allows you to define a custom ``__init__`` + method that can do pre-init work such as ``super().__init__()``, + and then call ``__attrs_init__()`` and ``__attrs_post_init__()``. + :param bool slots: Create a `slotted class ` that's more + memory-efficient. Slotted classes are generally superior to the default + dict classes, but have some gotchas you should know about, so we + encourage you to read the `glossary entry `. + :param bool frozen: Make instances immutable after initialization. If + someone attempts to modify a frozen instance, + `attr.exceptions.FrozenInstanceError` is raised. + + .. note:: + + 1. This is achieved by installing a custom ``__setattr__`` method + on your class, so you can't implement your own. + + 2. True immutability is impossible in Python. + + 3. This *does* have a minor a runtime performance `impact + ` when initializing new instances. In other words: + ``__init__`` is slightly slower with ``frozen=True``. + + 4. If a class is frozen, you cannot modify ``self`` in + ``__attrs_post_init__`` or a self-written ``__init__``. You can + circumvent that limitation by using + ``object.__setattr__(self, "attribute_name", value)``. + + 5. Subclasses of a frozen class are frozen too. + + :param bool weakref_slot: Make instances weak-referenceable. This has no + effect unless ``slots`` is also enabled. + :param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated + attributes (Python 3.6 and later only) from the class body. + + In this case, you **must** annotate every field. If ``attrs`` + encounters a field that is set to an `attr.ib` but lacks a type + annotation, an `attr.exceptions.UnannotatedAttributeError` is + raised. Use ``field_name: typing.Any = attr.ib(...)`` if you don't + want to set a type. + + If you assign a value to those attributes (e.g. ``x: int = 42``), that + value becomes the default value like if it were passed using + ``attr.ib(default=42)``. Passing an instance of `Factory` also + works as expected in most cases (see warning below). + + Attributes annotated as `typing.ClassVar`, and attributes that are + neither annotated nor set to an `attr.ib` are **ignored**. + + .. warning:: + For features that use the attribute name to create decorators (e.g. + `validators `), you still *must* assign `attr.ib` to + them. Otherwise Python will either not find the name or try to use + the default value to call e.g. ``validator`` on it. + + These errors can be quite confusing and probably the most common bug + report on our bug tracker. + + .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ + :param bool kw_only: Make all attributes keyword-only (Python 3+) + in the generated ``__init__`` (if ``init`` is ``False``, this + parameter is ignored). + :param bool cache_hash: Ensure that the object's hash code is computed + only once and stored on the object. If this is set to ``True``, + hashing must be either explicitly or implicitly enabled for this + class. If the hash code is cached, avoid any reassignments of + fields involved in hash code computation or mutations of the objects + those fields point to after object creation. If such changes occur, + the behavior of the object's hash code is undefined. + :param bool auto_exc: If the class subclasses `BaseException` + (which implicitly includes any subclass of any exception), the + following happens to behave like a well-behaved Python exceptions + class: + + - the values for *eq*, *order*, and *hash* are ignored and the + instances compare and hash by the instance's ids (N.B. ``attrs`` will + *not* remove existing implementations of ``__hash__`` or the equality + methods. It just won't add own ones.), + - all attributes that are either passed into ``__init__`` or have a + default value are additionally available as a tuple in the ``args`` + attribute, + - the value of *str* is ignored leaving ``__str__`` to base classes. + :param bool collect_by_mro: Setting this to `True` fixes the way ``attrs`` + collects attributes from base classes. The default behavior is + incorrect in certain cases of multiple inheritance. It should be on by + default but is kept off for backward-compatability. + + See issue `#428 `_ for + more details. + + :param Optional[bool] getstate_setstate: + .. note:: + This is usually only interesting for slotted classes and you should + probably just set *auto_detect* to `True`. + + If `True`, ``__getstate__`` and + ``__setstate__`` are generated and attached to the class. This is + necessary for slotted classes to be pickleable. If left `None`, it's + `True` by default for slotted classes and ``False`` for dict classes. + + If *auto_detect* is `True`, and *getstate_setstate* is left `None`, + and **either** ``__getstate__`` or ``__setstate__`` is detected directly + on the class (i.e. not inherited), it is set to `False` (this is usually + what you want). + + :param on_setattr: A callable that is run whenever the user attempts to set + an attribute (either by assignment like ``i.x = 42`` or by using + `setattr` like ``setattr(i, "x", 42)``). It receives the same arguments + as validators: the instance, the attribute that is being modified, and + the new value. + + If no exception is raised, the attribute is set to the return value of + the callable. + + If a list of callables is passed, they're automatically wrapped in an + `attr.setters.pipe`. + + :param Optional[callable] field_transformer: + A function that is called with the original class object and all + fields right before ``attrs`` finalizes the class. You can use + this, e.g., to automatically add converters or validators to + fields based on their types. See `transform-fields` for more details. + + .. versionadded:: 16.0.0 *slots* + .. versionadded:: 16.1.0 *frozen* + .. versionadded:: 16.3.0 *str* + .. versionadded:: 16.3.0 Support for ``__attrs_post_init__``. + .. versionchanged:: 17.1.0 + *hash* supports ``None`` as value which is also the default now. + .. versionadded:: 17.3.0 *auto_attribs* + .. versionchanged:: 18.1.0 + If *these* is passed, no attributes are deleted from the class body. + .. versionchanged:: 18.1.0 If *these* is ordered, the order is retained. + .. versionadded:: 18.2.0 *weakref_slot* + .. deprecated:: 18.2.0 + ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now raise a + `DeprecationWarning` if the classes compared are subclasses of + each other. ``__eq`` and ``__ne__`` never tried to compared subclasses + to each other. + .. versionchanged:: 19.2.0 + ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now do not consider + subclasses comparable anymore. + .. versionadded:: 18.2.0 *kw_only* + .. versionadded:: 18.2.0 *cache_hash* + .. versionadded:: 19.1.0 *auto_exc* + .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. + .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *auto_detect* + .. versionadded:: 20.1.0 *collect_by_mro* + .. versionadded:: 20.1.0 *getstate_setstate* + .. versionadded:: 20.1.0 *on_setattr* + .. versionadded:: 20.3.0 *field_transformer* + .. versionchanged:: 21.1.0 + ``init=False`` injects ``__attrs_init__`` + .. versionchanged:: 21.1.0 Support for ``__attrs_pre_init__`` + .. versionchanged:: 21.1.0 *cmp* undeprecated + """ + if auto_detect and PY2: + raise PythonTooOldError( + "auto_detect only works on Python 3 and later." + ) + + eq_, order_ = _determine_attrs_eq_order(cmp, eq, order, None) + hash_ = hash # work around the lack of nonlocal + + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + def wrap(cls): + + if getattr(cls, "__class__", None) is None: + raise TypeError("attrs only works with new-style classes.") + + is_frozen = frozen or _has_frozen_base_class(cls) + is_exc = auto_exc is True and issubclass(cls, BaseException) + has_own_setattr = auto_detect and _has_own_attribute( + cls, "__setattr__" + ) + + if has_own_setattr and is_frozen: + raise ValueError("Can't freeze a class with a custom __setattr__.") + + builder = _ClassBuilder( + cls, + these, + slots, + is_frozen, + weakref_slot, + _determine_whether_to_implement( + cls, + getstate_setstate, + auto_detect, + ("__getstate__", "__setstate__"), + default=slots, + ), + auto_attribs, + kw_only, + cache_hash, + is_exc, + collect_by_mro, + on_setattr, + has_own_setattr, + field_transformer, + ) + if _determine_whether_to_implement( + cls, repr, auto_detect, ("__repr__",) + ): + builder.add_repr(repr_ns) + if str is True: + builder.add_str() + + eq = _determine_whether_to_implement( + cls, eq_, auto_detect, ("__eq__", "__ne__") + ) + if not is_exc and eq is True: + builder.add_eq() + if not is_exc and _determine_whether_to_implement( + cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__") + ): + builder.add_order() + + builder.add_setattr() + + if ( + hash_ is None + and auto_detect is True + and _has_own_attribute(cls, "__hash__") + ): + hash = False + else: + hash = hash_ + if hash is not True and hash is not False and hash is not None: + # Can't use `hash in` because 1 == True for example. + raise TypeError( + "Invalid value for hash. Must be True, False, or None." + ) + elif hash is False or (hash is None and eq is False) or is_exc: + # Don't do anything. Should fall back to __object__'s __hash__ + # which is by id. + if cache_hash: + raise TypeError( + "Invalid value for cache_hash. To use hash caching," + " hashing must be either explicitly or implicitly " + "enabled." + ) + elif hash is True or ( + hash is None and eq is True and is_frozen is True + ): + # Build a __hash__ if told so, or if it's safe. + builder.add_hash() + else: + # Raise TypeError on attempts to hash. + if cache_hash: + raise TypeError( + "Invalid value for cache_hash. To use hash caching," + " hashing must be either explicitly or implicitly " + "enabled." + ) + builder.make_unhashable() + + if _determine_whether_to_implement( + cls, init, auto_detect, ("__init__",) + ): + builder.add_init() + else: + builder.add_attrs_init() + if cache_hash: + raise TypeError( + "Invalid value for cache_hash. To use hash caching," + " init must be True." + ) + + return builder.build_class() + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but ``None`` if used as `@attrs()`. + if maybe_cls is None: + return wrap + else: + return wrap(maybe_cls) + + +_attrs = attrs +""" +Internal alias so we can use it in functions that take an argument called +*attrs*. +""" + + +if PY2: + + def _has_frozen_base_class(cls): + """ + Check whether *cls* has a frozen ancestor by looking at its + __setattr__. + """ + return ( + getattr(cls.__setattr__, "__module__", None) + == _frozen_setattrs.__module__ + and cls.__setattr__.__name__ == _frozen_setattrs.__name__ + ) + + +else: + + def _has_frozen_base_class(cls): + """ + Check whether *cls* has a frozen ancestor by looking at its + __setattr__. + """ + return cls.__setattr__ == _frozen_setattrs + + +def _generate_unique_filename(cls, func_name): + """ + Create a "filename" suitable for a function being generated. + """ + unique_id = uuid.uuid4() + extra = "" + count = 1 + + while True: + unique_filename = "".format( + func_name, + cls.__module__, + getattr(cls, "__qualname__", cls.__name__), + extra, + ) + # To handle concurrency we essentially "reserve" our spot in + # the linecache with a dummy line. The caller can then + # set this value correctly. + cache_line = (1, None, (str(unique_id),), unique_filename) + if ( + linecache.cache.setdefault(unique_filename, cache_line) + == cache_line + ): + return unique_filename + + # Looks like this spot is taken. Try again. + count += 1 + extra = "-{0}".format(count) + + +def _make_hash(cls, attrs, frozen, cache_hash): + attrs = tuple( + a for a in attrs if a.hash is True or (a.hash is None and a.eq is True) + ) + + tab = " " + + unique_filename = _generate_unique_filename(cls, "hash") + type_hash = hash(unique_filename) + + hash_def = "def __hash__(self" + hash_func = "hash((" + closing_braces = "))" + if not cache_hash: + hash_def += "):" + else: + if not PY2: + hash_def += ", *" + + hash_def += ( + ", _cache_wrapper=" + + "__import__('attr._make')._make._CacheHashWrapper):" + ) + hash_func = "_cache_wrapper(" + hash_func + closing_braces += ")" + + method_lines = [hash_def] + + def append_hash_computation_lines(prefix, indent): + """ + Generate the code for actually computing the hash code. + Below this will either be returned directly or used to compute + a value which is then cached, depending on the value of cache_hash + """ + + method_lines.extend( + [ + indent + prefix + hash_func, + indent + " %d," % (type_hash,), + ] + ) + + for a in attrs: + method_lines.append(indent + " self.%s," % a.name) + + method_lines.append(indent + " " + closing_braces) + + if cache_hash: + method_lines.append(tab + "if self.%s is None:" % _hash_cache_field) + if frozen: + append_hash_computation_lines( + "object.__setattr__(self, '%s', " % _hash_cache_field, tab * 2 + ) + method_lines.append(tab * 2 + ")") # close __setattr__ + else: + append_hash_computation_lines( + "self.%s = " % _hash_cache_field, tab * 2 + ) + method_lines.append(tab + "return self.%s" % _hash_cache_field) + else: + append_hash_computation_lines("return ", tab) + + script = "\n".join(method_lines) + return _make_method("__hash__", script, unique_filename) + + +def _add_hash(cls, attrs): + """ + Add a hash method to *cls*. + """ + cls.__hash__ = _make_hash(cls, attrs, frozen=False, cache_hash=False) + return cls + + +def _make_ne(): + """ + Create __ne__ method. + """ + + def __ne__(self, other): + """ + Check equality and either forward a NotImplemented or + return the result negated. + """ + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + + return not result + + return __ne__ + + +def _make_eq(cls, attrs): + """ + Create __eq__ method for *cls* with *attrs*. + """ + attrs = [a for a in attrs if a.eq] + + unique_filename = _generate_unique_filename(cls, "eq") + lines = [ + "def __eq__(self, other):", + " if other.__class__ is not self.__class__:", + " return NotImplemented", + ] + + # We can't just do a big self.x = other.x and... clause due to + # irregularities like nan == nan is false but (nan,) == (nan,) is true. + globs = {} + if attrs: + lines.append(" return (") + others = [" ) == ("] + for a in attrs: + if a.eq_key: + cmp_name = "_%s_key" % (a.name,) + # Add the key function to the global namespace + # of the evaluated function. + globs[cmp_name] = a.eq_key + lines.append( + " %s(self.%s)," + % ( + cmp_name, + a.name, + ) + ) + others.append( + " %s(other.%s)," + % ( + cmp_name, + a.name, + ) + ) + else: + lines.append(" self.%s," % (a.name,)) + others.append(" other.%s," % (a.name,)) + + lines += others + [" )"] + else: + lines.append(" return True") + + script = "\n".join(lines) + + return _make_method("__eq__", script, unique_filename, globs) + + +def _make_order(cls, attrs): + """ + Create ordering methods for *cls* with *attrs*. + """ + attrs = [a for a in attrs if a.order] + + def attrs_to_tuple(obj): + """ + Save us some typing. + """ + return tuple( + key(value) if key else value + for value, key in ( + (getattr(obj, a.name), a.order_key) for a in attrs + ) + ) + + def __lt__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) < attrs_to_tuple(other) + + return NotImplemented + + def __le__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) <= attrs_to_tuple(other) + + return NotImplemented + + def __gt__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) > attrs_to_tuple(other) + + return NotImplemented + + def __ge__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) >= attrs_to_tuple(other) + + return NotImplemented + + return __lt__, __le__, __gt__, __ge__ + + +def _add_eq(cls, attrs=None): + """ + Add equality methods to *cls* with *attrs*. + """ + if attrs is None: + attrs = cls.__attrs_attrs__ + + cls.__eq__ = _make_eq(cls, attrs) + cls.__ne__ = _make_ne() + + return cls + + +_already_repring = threading.local() + + +def _make_repr(attrs, ns): + """ + Make a repr method that includes relevant *attrs*, adding *ns* to the full + name. + """ + + # Figure out which attributes to include, and which function to use to + # format them. The a.repr value can be either bool or a custom callable. + attr_names_with_reprs = tuple( + (a.name, repr if a.repr is True else a.repr) + for a in attrs + if a.repr is not False + ) + + def __repr__(self): + """ + Automatically created by attrs. + """ + try: + working_set = _already_repring.working_set + except AttributeError: + working_set = set() + _already_repring.working_set = working_set + + if id(self) in working_set: + return "..." + real_cls = self.__class__ + if ns is None: + qualname = getattr(real_cls, "__qualname__", None) + if qualname is not None: + class_name = qualname.rsplit(">.", 1)[-1] + else: + class_name = real_cls.__name__ + else: + class_name = ns + "." + real_cls.__name__ + + # Since 'self' remains on the stack (i.e.: strongly referenced) for the + # duration of this call, it's safe to depend on id(...) stability, and + # not need to track the instance and therefore worry about properties + # like weakref- or hash-ability. + working_set.add(id(self)) + try: + result = [class_name, "("] + first = True + for name, attr_repr in attr_names_with_reprs: + if first: + first = False + else: + result.append(", ") + result.extend( + (name, "=", attr_repr(getattr(self, name, NOTHING))) + ) + return "".join(result) + ")" + finally: + working_set.remove(id(self)) + + return __repr__ + + +def _add_repr(cls, ns=None, attrs=None): + """ + Add a repr method to *cls*. + """ + if attrs is None: + attrs = cls.__attrs_attrs__ + + cls.__repr__ = _make_repr(attrs, ns) + return cls + + +def fields(cls): + """ + Return the tuple of ``attrs`` attributes for a class. + + The tuple also allows accessing the fields by their names (see below for + examples). + + :param type cls: Class to introspect. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + :rtype: tuple (with name accessors) of `attr.Attribute` + + .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields + by name. + """ + if not isclass(cls): + raise TypeError("Passed object must be a class.") + attrs = getattr(cls, "__attrs_attrs__", None) + if attrs is None: + raise NotAnAttrsClassError( + "{cls!r} is not an attrs-decorated class.".format(cls=cls) + ) + return attrs + + +def fields_dict(cls): + """ + Return an ordered dictionary of ``attrs`` attributes for a class, whose + keys are the attribute names. + + :param type cls: Class to introspect. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + :rtype: an ordered dict where keys are attribute names and values are + `attr.Attribute`\\ s. This will be a `dict` if it's + naturally ordered like on Python 3.6+ or an + :class:`~collections.OrderedDict` otherwise. + + .. versionadded:: 18.1.0 + """ + if not isclass(cls): + raise TypeError("Passed object must be a class.") + attrs = getattr(cls, "__attrs_attrs__", None) + if attrs is None: + raise NotAnAttrsClassError( + "{cls!r} is not an attrs-decorated class.".format(cls=cls) + ) + return ordered_dict(((a.name, a) for a in attrs)) + + +def validate(inst): + """ + Validate all attributes on *inst* that have a validator. + + Leaves all exceptions through. + + :param inst: Instance of a class with ``attrs`` attributes. + """ + if _config._run_validators is False: + return + + for a in fields(inst.__class__): + v = a.validator + if v is not None: + v(inst, a, getattr(inst, a.name)) + + +def _is_slot_cls(cls): + return "__slots__" in cls.__dict__ + + +def _is_slot_attr(a_name, base_attr_map): + """ + Check if the attribute name comes from a slot class. + """ + return a_name in base_attr_map and _is_slot_cls(base_attr_map[a_name]) + + +def _make_init( + cls, + attrs, + pre_init, + post_init, + frozen, + slots, + cache_hash, + base_attr_map, + is_exc, + has_global_on_setattr, + attrs_init, +): + if frozen and has_global_on_setattr: + raise ValueError("Frozen classes can't use on_setattr.") + + needs_cached_setattr = cache_hash or frozen + filtered_attrs = [] + attr_dict = {} + for a in attrs: + if not a.init and a.default is NOTHING: + continue + + filtered_attrs.append(a) + attr_dict[a.name] = a + + if a.on_setattr is not None: + if frozen is True: + raise ValueError("Frozen classes can't use on_setattr.") + + needs_cached_setattr = True + elif ( + has_global_on_setattr and a.on_setattr is not setters.NO_OP + ) or _is_slot_attr(a.name, base_attr_map): + needs_cached_setattr = True + + unique_filename = _generate_unique_filename(cls, "init") + + script, globs, annotations = _attrs_to_init_script( + filtered_attrs, + frozen, + slots, + pre_init, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_global_on_setattr, + attrs_init, + ) + if cls.__module__ in sys.modules: + # This makes typing.get_type_hints(CLS.__init__) resolve string types. + globs.update(sys.modules[cls.__module__].__dict__) + + globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) + + if needs_cached_setattr: + # Save the lookup overhead in __init__ if we need to circumvent + # setattr hooks. + globs["_cached_setattr"] = _obj_setattr + + init = _make_method( + "__attrs_init__" if attrs_init else "__init__", + script, + unique_filename, + globs, + ) + init.__annotations__ = annotations + + return init + + +def _setattr(attr_name, value_var, has_on_setattr): + """ + Use the cached object.setattr to set *attr_name* to *value_var*. + """ + return "_setattr('%s', %s)" % (attr_name, value_var) + + +def _setattr_with_converter(attr_name, value_var, has_on_setattr): + """ + Use the cached object.setattr to set *attr_name* to *value_var*, but run + its converter first. + """ + return "_setattr('%s', %s(%s))" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + +def _assign(attr_name, value, has_on_setattr): + """ + Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise + relegate to _setattr. + """ + if has_on_setattr: + return _setattr(attr_name, value, True) + + return "self.%s = %s" % (attr_name, value) + + +def _assign_with_converter(attr_name, value_var, has_on_setattr): + """ + Unless *attr_name* has an on_setattr hook, use normal assignment after + conversion. Otherwise relegate to _setattr_with_converter. + """ + if has_on_setattr: + return _setattr_with_converter(attr_name, value_var, True) + + return "self.%s = %s(%s)" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + +if PY2: + + def _unpack_kw_only_py2(attr_name, default=None): + """ + Unpack *attr_name* from _kw_only dict. + """ + if default is not None: + arg_default = ", %s" % default + else: + arg_default = "" + return "%s = _kw_only.pop('%s'%s)" % ( + attr_name, + attr_name, + arg_default, + ) + + def _unpack_kw_only_lines_py2(kw_only_args): + """ + Unpack all *kw_only_args* from _kw_only dict and handle errors. + + Given a list of strings "{attr_name}" and "{attr_name}={default}" + generates list of lines of code that pop attrs from _kw_only dict and + raise TypeError similar to builtin if required attr is missing or + extra key is passed. + + >>> print("\n".join(_unpack_kw_only_lines_py2(["a", "b=42"]))) + try: + a = _kw_only.pop('a') + b = _kw_only.pop('b', 42) + except KeyError as _key_error: + raise TypeError( + ... + if _kw_only: + raise TypeError( + ... + """ + lines = ["try:"] + lines.extend( + " " + _unpack_kw_only_py2(*arg.split("=")) + for arg in kw_only_args + ) + lines += """\ +except KeyError as _key_error: + raise TypeError( + '__init__() missing required keyword-only argument: %s' % _key_error + ) +if _kw_only: + raise TypeError( + '__init__() got an unexpected keyword argument %r' + % next(iter(_kw_only)) + ) +""".split( + "\n" + ) + return lines + + +def _attrs_to_init_script( + attrs, + frozen, + slots, + pre_init, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_global_on_setattr, + attrs_init, +): + """ + Return a script of an initializer for *attrs* and a dict of globals. + + The globals are expected by the generated script. + + If *frozen* is True, we cannot set the attributes directly so we use + a cached ``object.__setattr__``. + """ + lines = [] + if pre_init: + lines.append("self.__attrs_pre_init__()") + + if needs_cached_setattr: + lines.append( + # Circumvent the __setattr__ descriptor to save one lookup per + # assignment. + # Note _setattr will be used again below if cache_hash is True + "_setattr = _cached_setattr.__get__(self, self.__class__)" + ) + + if frozen is True: + if slots is True: + fmt_setter = _setattr + fmt_setter_with_converter = _setattr_with_converter + else: + # Dict frozen classes assign directly to __dict__. + # But only if the attribute doesn't come from an ancestor slot + # class. + # Note _inst_dict will be used again below if cache_hash is True + lines.append("_inst_dict = self.__dict__") + + def fmt_setter(attr_name, value_var, has_on_setattr): + if _is_slot_attr(attr_name, base_attr_map): + return _setattr(attr_name, value_var, has_on_setattr) + + return "_inst_dict['%s'] = %s" % (attr_name, value_var) + + def fmt_setter_with_converter( + attr_name, value_var, has_on_setattr + ): + if has_on_setattr or _is_slot_attr(attr_name, base_attr_map): + return _setattr_with_converter( + attr_name, value_var, has_on_setattr + ) + + return "_inst_dict['%s'] = %s(%s)" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + else: + # Not frozen. + fmt_setter = _assign + fmt_setter_with_converter = _assign_with_converter + + args = [] + kw_only_args = [] + attrs_to_validate = [] + + # This is a dictionary of names to validator and converter callables. + # Injecting this into __init__ globals lets us avoid lookups. + names_for_globals = {} + annotations = {"return": None} + + for a in attrs: + if a.validator: + attrs_to_validate.append(a) + + attr_name = a.name + has_on_setattr = a.on_setattr is not None or ( + a.on_setattr is not setters.NO_OP and has_global_on_setattr + ) + arg_name = a.name.lstrip("_") + + has_factory = isinstance(a.default, Factory) + if has_factory and a.default.takes_self: + maybe_self = "self" + else: + maybe_self = "" + + if a.init is False: + if has_factory: + init_factory_name = _init_factory_pat.format(a.name) + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, + init_factory_name + "(%s)" % (maybe_self,), + has_on_setattr, + ) + ) + conv_name = _init_converter_pat % (a.name,) + names_for_globals[conv_name] = a.converter + else: + lines.append( + fmt_setter( + attr_name, + init_factory_name + "(%s)" % (maybe_self,), + has_on_setattr, + ) + ) + names_for_globals[init_factory_name] = a.default.factory + else: + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, + "attr_dict['%s'].default" % (attr_name,), + has_on_setattr, + ) + ) + conv_name = _init_converter_pat % (a.name,) + names_for_globals[conv_name] = a.converter + else: + lines.append( + fmt_setter( + attr_name, + "attr_dict['%s'].default" % (attr_name,), + has_on_setattr, + ) + ) + elif a.default is not NOTHING and not has_factory: + arg = "%s=attr_dict['%s'].default" % (arg_name, attr_name) + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) + + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) + names_for_globals[ + _init_converter_pat % (a.name,) + ] = a.converter + else: + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + + elif has_factory: + arg = "%s=NOTHING" % (arg_name,) + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) + lines.append("if %s is not NOTHING:" % (arg_name,)) + + init_factory_name = _init_factory_pat.format(a.name) + if a.converter is not None: + lines.append( + " " + + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) + lines.append("else:") + lines.append( + " " + + fmt_setter_with_converter( + attr_name, + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, + ) + ) + names_for_globals[ + _init_converter_pat % (a.name,) + ] = a.converter + else: + lines.append( + " " + fmt_setter(attr_name, arg_name, has_on_setattr) + ) + lines.append("else:") + lines.append( + " " + + fmt_setter( + attr_name, + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, + ) + ) + names_for_globals[init_factory_name] = a.default.factory + else: + if a.kw_only: + kw_only_args.append(arg_name) + else: + args.append(arg_name) + + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) + names_for_globals[ + _init_converter_pat % (a.name,) + ] = a.converter + else: + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + + if a.init is True: + if a.type is not None and a.converter is None: + annotations[arg_name] = a.type + elif a.converter is not None and not PY2: + # Try to get the type from the converter. + sig = None + try: + sig = inspect.signature(a.converter) + except (ValueError, TypeError): # inspect failed + pass + if sig: + sig_params = list(sig.parameters.values()) + if ( + sig_params + and sig_params[0].annotation + is not inspect.Parameter.empty + ): + annotations[arg_name] = sig_params[0].annotation + + if attrs_to_validate: # we can skip this if there are no validators. + names_for_globals["_config"] = _config + lines.append("if _config._run_validators is True:") + for a in attrs_to_validate: + val_name = "__attr_validator_" + a.name + attr_name = "__attr_" + a.name + lines.append( + " %s(self, %s, self.%s)" % (val_name, attr_name, a.name) + ) + names_for_globals[val_name] = a.validator + names_for_globals[attr_name] = a + + if post_init: + lines.append("self.__attrs_post_init__()") + + # because this is set only after __attrs_post_init is called, a crash + # will result if post-init tries to access the hash code. This seemed + # preferable to setting this beforehand, in which case alteration to + # field values during post-init combined with post-init accessing the + # hash code would result in silent bugs. + if cache_hash: + if frozen: + if slots: + # if frozen and slots, then _setattr defined above + init_hash_cache = "_setattr('%s', %s)" + else: + # if frozen and not slots, then _inst_dict defined above + init_hash_cache = "_inst_dict['%s'] = %s" + else: + init_hash_cache = "self.%s = %s" + lines.append(init_hash_cache % (_hash_cache_field, "None")) + + # For exceptions we rely on BaseException.__init__ for proper + # initialization. + if is_exc: + vals = ",".join("self." + a.name for a in attrs if a.init) + + lines.append("BaseException.__init__(self, %s)" % (vals,)) + + args = ", ".join(args) + if kw_only_args: + if PY2: + lines = _unpack_kw_only_lines_py2(kw_only_args) + lines + + args += "%s**_kw_only" % (", " if args else "",) # leading comma + else: + args += "%s*, %s" % ( + ", " if args else "", # leading comma + ", ".join(kw_only_args), # kw_only args + ) + return ( + """\ +def {init_name}(self, {args}): + {lines} +""".format( + init_name=("__attrs_init__" if attrs_init else "__init__"), + args=args, + lines="\n ".join(lines) if lines else "pass", + ), + names_for_globals, + annotations, + ) + + +class Attribute(object): + """ + *Read-only* representation of an attribute. + + Instances of this class are frequently used for introspection purposes + like: + + - `fields` returns a tuple of them. + - Validators get them passed as the first argument. + - The *field transformer* hook receives a list of them. + + :attribute name: The name of the attribute. + :attribute inherited: Whether or not that attribute has been inherited from + a base class. + + Plus *all* arguments of `attr.ib` (except for ``factory`` + which is only syntactic sugar for ``default=Factory(...)``. + + .. versionadded:: 20.1.0 *inherited* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.2.0 *inherited* is not taken into account for + equality checks and hashing anymore. + .. versionadded:: 21.1.0 *eq_key* and *order_key* + + For the full version history of the fields, see `attr.ib`. + """ + + __slots__ = ( + "name", + "default", + "validator", + "repr", + "eq", + "eq_key", + "order", + "order_key", + "hash", + "init", + "metadata", + "type", + "converter", + "kw_only", + "inherited", + "on_setattr", + ) + + def __init__( + self, + name, + default, + validator, + repr, + cmp, # XXX: unused, remove along with other cmp code. + hash, + init, + inherited, + metadata=None, + type=None, + converter=None, + kw_only=False, + eq=None, + eq_key=None, + order=None, + order_key=None, + on_setattr=None, + ): + eq, eq_key, order, order_key = _determine_attrib_eq_order( + cmp, eq_key or eq, order_key or order, True + ) + + # Cache this descriptor here to speed things up later. + bound_setattr = _obj_setattr.__get__(self, Attribute) + + # Despite the big red warning, people *do* instantiate `Attribute` + # themselves. + bound_setattr("name", name) + bound_setattr("default", default) + bound_setattr("validator", validator) + bound_setattr("repr", repr) + bound_setattr("eq", eq) + bound_setattr("eq_key", eq_key) + bound_setattr("order", order) + bound_setattr("order_key", order_key) + bound_setattr("hash", hash) + bound_setattr("init", init) + bound_setattr("converter", converter) + bound_setattr( + "metadata", + ( + metadata_proxy(metadata) + if metadata + else _empty_metadata_singleton + ), + ) + bound_setattr("type", type) + bound_setattr("kw_only", kw_only) + bound_setattr("inherited", inherited) + bound_setattr("on_setattr", on_setattr) + + def __setattr__(self, name, value): + raise FrozenInstanceError() + + @classmethod + def from_counting_attr(cls, name, ca, type=None): + # type holds the annotated value. deal with conflicts: + if type is None: + type = ca.type + elif ca.type is not None: + raise ValueError( + "Type annotation and type argument cannot both be present" + ) + inst_dict = { + k: getattr(ca, k) + for k in Attribute.__slots__ + if k + not in ( + "name", + "validator", + "default", + "type", + "inherited", + ) # exclude methods and deprecated alias + } + return cls( + name=name, + validator=ca._validator, + default=ca._default, + type=type, + cmp=None, + inherited=False, + **inst_dict + ) + + @property + def cmp(self): + """ + Simulate the presence of a cmp attribute and warn. + """ + warnings.warn(_CMP_DEPRECATION, DeprecationWarning, stacklevel=2) + + return self.eq and self.order + + # Don't use attr.evolve since fields(Attribute) doesn't work + def evolve(self, **changes): + """ + Copy *self* and apply *changes*. + + This works similarly to `attr.evolve` but that function does not work + with ``Attribute``. + + It is mainly meant to be used for `transform-fields`. + + .. versionadded:: 20.3.0 + """ + new = copy.copy(self) + + new._setattrs(changes.items()) + + return new + + # Don't use _add_pickle since fields(Attribute) doesn't work + def __getstate__(self): + """ + Play nice with pickle. + """ + return tuple( + getattr(self, name) if name != "metadata" else dict(self.metadata) + for name in self.__slots__ + ) + + def __setstate__(self, state): + """ + Play nice with pickle. + """ + self._setattrs(zip(self.__slots__, state)) + + def _setattrs(self, name_values_pairs): + bound_setattr = _obj_setattr.__get__(self, Attribute) + for name, value in name_values_pairs: + if name != "metadata": + bound_setattr(name, value) + else: + bound_setattr( + name, + metadata_proxy(value) + if value + else _empty_metadata_singleton, + ) + + +_a = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=(name != "metadata"), + init=True, + inherited=False, + ) + for name in Attribute.__slots__ +] + +Attribute = _add_hash( + _add_eq( + _add_repr(Attribute, attrs=_a), + attrs=[a for a in _a if a.name != "inherited"], + ), + attrs=[a for a in _a if a.hash and a.name != "inherited"], +) + + +class _CountingAttr(object): + """ + Intermediate representation of attributes that uses a counter to preserve + the order in which the attributes have been defined. + + *Internal* data structure of the attrs library. Running into is most + likely the result of a bug like a forgotten `@attr.s` decorator. + """ + + __slots__ = ( + "counter", + "_default", + "repr", + "eq", + "eq_key", + "order", + "order_key", + "hash", + "init", + "metadata", + "_validator", + "converter", + "type", + "kw_only", + "on_setattr", + ) + __attrs_attrs__ = tuple( + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + hash=True, + init=True, + kw_only=False, + eq=True, + eq_key=None, + order=False, + order_key=None, + inherited=False, + on_setattr=None, + ) + for name in ( + "counter", + "_default", + "repr", + "eq", + "order", + "hash", + "init", + "on_setattr", + ) + ) + ( + Attribute( + name="metadata", + default=None, + validator=None, + repr=True, + cmp=None, + hash=False, + init=True, + kw_only=False, + eq=True, + eq_key=None, + order=False, + order_key=None, + inherited=False, + on_setattr=None, + ), + ) + cls_counter = 0 + + def __init__( + self, + default, + validator, + repr, + cmp, + hash, + init, + converter, + metadata, + type, + kw_only, + eq, + eq_key, + order, + order_key, + on_setattr, + ): + _CountingAttr.cls_counter += 1 + self.counter = _CountingAttr.cls_counter + self._default = default + self._validator = validator + self.converter = converter + self.repr = repr + self.eq = eq + self.eq_key = eq_key + self.order = order + self.order_key = order_key + self.hash = hash + self.init = init + self.metadata = metadata + self.type = type + self.kw_only = kw_only + self.on_setattr = on_setattr + + def validator(self, meth): + """ + Decorator that adds *meth* to the list of validators. + + Returns *meth* unchanged. + + .. versionadded:: 17.1.0 + """ + if self._validator is None: + self._validator = meth + else: + self._validator = and_(self._validator, meth) + return meth + + def default(self, meth): + """ + Decorator that allows to set the default for an attribute. + + Returns *meth* unchanged. + + :raises DefaultAlreadySetError: If default has been set before. + + .. versionadded:: 17.1.0 + """ + if self._default is not NOTHING: + raise DefaultAlreadySetError() + + self._default = Factory(meth, takes_self=True) + + return meth + + +_CountingAttr = _add_eq(_add_repr(_CountingAttr)) + + +class Factory(object): + """ + Stores a factory callable. + + If passed as the default value to `attr.ib`, the factory is used to + generate a new value. + + :param callable factory: A callable that takes either none or exactly one + mandatory positional argument depending on *takes_self*. + :param bool takes_self: Pass the partially initialized instance that is + being initialized as a positional argument. + + .. versionadded:: 17.1.0 *takes_self* + """ + + __slots__ = ("factory", "takes_self") + + def __init__(self, factory, takes_self=False): + """ + `Factory` is part of the default machinery so if we want a default + value here, we have to implement it ourselves. + """ + self.factory = factory + self.takes_self = takes_self + + def __getstate__(self): + """ + Play nice with pickle. + """ + return tuple(getattr(self, name) for name in self.__slots__) + + def __setstate__(self, state): + """ + Play nice with pickle. + """ + for name, value in zip(self.__slots__, state): + setattr(self, name, value) + + +_f = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=True, + init=True, + inherited=False, + ) + for name in Factory.__slots__ +] + +Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f) + + +def make_class(name, attrs, bases=(object,), **attributes_arguments): + """ + A quick way to create a new class called *name* with *attrs*. + + :param str name: The name for the new class. + + :param attrs: A list of names or a dictionary of mappings of names to + attributes. + + If *attrs* is a list or an ordered dict (`dict` on Python 3.6+, + `collections.OrderedDict` otherwise), the order is deduced from + the order of the names or attributes inside *attrs*. Otherwise the + order of the definition of the attributes is used. + :type attrs: `list` or `dict` + + :param tuple bases: Classes that the new class will subclass. + + :param attributes_arguments: Passed unmodified to `attr.s`. + + :return: A new class with *attrs*. + :rtype: type + + .. versionadded:: 17.1.0 *bases* + .. versionchanged:: 18.1.0 If *attrs* is ordered, the order is retained. + """ + if isinstance(attrs, dict): + cls_dict = attrs + elif isinstance(attrs, (list, tuple)): + cls_dict = dict((a, attrib()) for a in attrs) + else: + raise TypeError("attrs argument must be a dict or a list.") + + pre_init = cls_dict.pop("__attrs_pre_init__", None) + post_init = cls_dict.pop("__attrs_post_init__", None) + user_init = cls_dict.pop("__init__", None) + + body = {} + if pre_init is not None: + body["__attrs_pre_init__"] = pre_init + if post_init is not None: + body["__attrs_post_init__"] = post_init + if user_init is not None: + body["__init__"] = user_init + + type_ = new_class(name, bases, {}, lambda ns: ns.update(body)) + + # For pickling to work, the __module__ variable needs to be set to the + # frame where the class is created. Bypass this step in environments where + # sys._getframe is not defined (Jython for example) or sys._getframe is not + # defined for arguments greater than 0 (IronPython). + try: + type_.__module__ = sys._getframe(1).f_globals.get( + "__name__", "__main__" + ) + except (AttributeError, ValueError): + pass + + # We do it here for proper warnings with meaningful stacklevel. + cmp = attributes_arguments.pop("cmp", None) + ( + attributes_arguments["eq"], + attributes_arguments["order"], + ) = _determine_attrs_eq_order( + cmp, + attributes_arguments.get("eq"), + attributes_arguments.get("order"), + True, + ) + + return _attrs(these=cls_dict, **attributes_arguments)(type_) + + +# These are required by within this module so we define them here and merely +# import into .validators / .converters. + + +@attrs(slots=True, hash=True) +class _AndValidator(object): + """ + Compose many validators to a single one. + """ + + _validators = attrib() + + def __call__(self, inst, attr, value): + for v in self._validators: + v(inst, attr, value) + + +def and_(*validators): + """ + A validator that composes multiple validators into one. + + When called on a value, it runs all wrapped validators. + + :param callables validators: Arbitrary number of validators. + + .. versionadded:: 17.1.0 + """ + vals = [] + for validator in validators: + vals.extend( + validator._validators + if isinstance(validator, _AndValidator) + else [validator] + ) + + return _AndValidator(tuple(vals)) + + +def pipe(*converters): + """ + A converter that composes multiple converters into one. + + When called on a value, it runs all wrapped converters, returning the + *last* value. + + Type annotations will be inferred from the wrapped converters', if + they have any. + + :param callables converters: Arbitrary number of converters. + + .. versionadded:: 20.1.0 + """ + + def pipe_converter(val): + for converter in converters: + val = converter(val) + + return val + + if not PY2: + if not converters: + # If the converter list is empty, pipe_converter is the identity. + A = typing.TypeVar("A") + pipe_converter.__annotations__ = {"val": A, "return": A} + else: + # Get parameter type. + sig = None + try: + sig = inspect.signature(converters[0]) + except (ValueError, TypeError): # inspect failed + pass + if sig: + params = list(sig.parameters.values()) + if ( + params + and params[0].annotation is not inspect.Parameter.empty + ): + pipe_converter.__annotations__["val"] = params[ + 0 + ].annotation + # Get return type. + sig = None + try: + sig = inspect.signature(converters[-1]) + except (ValueError, TypeError): # inspect failed + pass + if sig and sig.return_annotation is not inspect.Signature().empty: + pipe_converter.__annotations__[ + "return" + ] = sig.return_annotation + + return pipe_converter diff --git a/openpype/hosts/fusion/vendor/attr/_next_gen.py b/openpype/hosts/fusion/vendor/attr/_next_gen.py new file mode 100644 index 0000000000..fab0af966a --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_next_gen.py @@ -0,0 +1,158 @@ +""" +These are Python 3.6+-only and keyword-only APIs that call `attr.s` and +`attr.ib` with different default values. +""" + +from functools import partial + +from attr.exceptions import UnannotatedAttributeError + +from . import setters +from ._make import NOTHING, _frozen_setattrs, attrib, attrs + + +def define( + maybe_cls=None, + *, + these=None, + repr=None, + hash=None, + init=None, + slots=True, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=None, + kw_only=False, + cache_hash=False, + auto_exc=True, + eq=None, + order=False, + auto_detect=True, + getstate_setstate=None, + on_setattr=None, + field_transformer=None, +): + r""" + The only behavioral differences are the handling of the *auto_attribs* + option: + + :param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves + exactly like `attr.s`. If left `None`, `attr.s` will try to guess: + + 1. If any attributes are annotated and no unannotated `attr.ib`\ s + are found, it assumes *auto_attribs=True*. + 2. Otherwise it assumes *auto_attribs=False* and tries to collect + `attr.ib`\ s. + + and that mutable classes (``frozen=False``) validate on ``__setattr__``. + + .. versionadded:: 20.1.0 + """ + + def do_it(cls, auto_attribs): + return attrs( + maybe_cls=cls, + these=these, + repr=repr, + hash=hash, + init=init, + slots=slots, + frozen=frozen, + weakref_slot=weakref_slot, + str=str, + auto_attribs=auto_attribs, + kw_only=kw_only, + cache_hash=cache_hash, + auto_exc=auto_exc, + eq=eq, + order=order, + auto_detect=auto_detect, + collect_by_mro=True, + getstate_setstate=getstate_setstate, + on_setattr=on_setattr, + field_transformer=field_transformer, + ) + + def wrap(cls): + """ + Making this a wrapper ensures this code runs during class creation. + + We also ensure that frozen-ness of classes is inherited. + """ + nonlocal frozen, on_setattr + + had_on_setattr = on_setattr not in (None, setters.NO_OP) + + # By default, mutable classes validate on setattr. + if frozen is False and on_setattr is None: + on_setattr = setters.validate + + # However, if we subclass a frozen class, we inherit the immutability + # and disable on_setattr. + for base_cls in cls.__bases__: + if base_cls.__setattr__ is _frozen_setattrs: + if had_on_setattr: + raise ValueError( + "Frozen classes can't use on_setattr " + "(frozen-ness was inherited)." + ) + + on_setattr = setters.NO_OP + break + + if auto_attribs is not None: + return do_it(cls, auto_attribs) + + try: + return do_it(cls, True) + except UnannotatedAttributeError: + return do_it(cls, False) + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but ``None`` if used as `@attrs()`. + if maybe_cls is None: + return wrap + else: + return wrap(maybe_cls) + + +mutable = define +frozen = partial(define, frozen=True, on_setattr=None) + + +def field( + *, + default=NOTHING, + validator=None, + repr=True, + hash=None, + init=True, + metadata=None, + converter=None, + factory=None, + kw_only=False, + eq=None, + order=None, + on_setattr=None, +): + """ + Identical to `attr.ib`, except keyword-only and with some arguments + removed. + + .. versionadded:: 20.1.0 + """ + return attrib( + default=default, + validator=validator, + repr=repr, + hash=hash, + init=init, + metadata=metadata, + converter=converter, + factory=factory, + kw_only=kw_only, + eq=eq, + order=order, + on_setattr=on_setattr, + ) diff --git a/openpype/hosts/fusion/vendor/attr/_version_info.py b/openpype/hosts/fusion/vendor/attr/_version_info.py new file mode 100644 index 0000000000..014e78a1b4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_version_info.py @@ -0,0 +1,85 @@ +from __future__ import absolute_import, division, print_function + +from functools import total_ordering + +from ._funcs import astuple +from ._make import attrib, attrs + + +@total_ordering +@attrs(eq=False, order=False, slots=True, frozen=True) +class VersionInfo(object): + """ + A version object that can be compared to tuple of length 1--4: + + >>> attr.VersionInfo(19, 1, 0, "final") <= (19, 2) + True + >>> attr.VersionInfo(19, 1, 0, "final") < (19, 1, 1) + True + >>> vi = attr.VersionInfo(19, 2, 0, "final") + >>> vi < (19, 1, 1) + False + >>> vi < (19,) + False + >>> vi == (19, 2,) + True + >>> vi == (19, 2, 1) + False + + .. versionadded:: 19.2 + """ + + year = attrib(type=int) + minor = attrib(type=int) + micro = attrib(type=int) + releaselevel = attrib(type=str) + + @classmethod + def _from_version_string(cls, s): + """ + Parse *s* and return a _VersionInfo. + """ + v = s.split(".") + if len(v) == 3: + v.append("final") + + return cls( + year=int(v[0]), minor=int(v[1]), micro=int(v[2]), releaselevel=v[3] + ) + + def _ensure_tuple(self, other): + """ + Ensure *other* is a tuple of a valid length. + + Returns a possibly transformed *other* and ourselves as a tuple of + the same length as *other*. + """ + + if self.__class__ is other.__class__: + other = astuple(other) + + if not isinstance(other, tuple): + raise NotImplementedError + + if not (1 <= len(other) <= 4): + raise NotImplementedError + + return astuple(self)[: len(other)], other + + def __eq__(self, other): + try: + us, them = self._ensure_tuple(other) + except NotImplementedError: + return NotImplemented + + return us == them + + def __lt__(self, other): + try: + us, them = self._ensure_tuple(other) + except NotImplementedError: + return NotImplemented + + # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't + # have to do anything special with releaselevel for now. + return us < them diff --git a/openpype/hosts/fusion/vendor/attr/_version_info.pyi b/openpype/hosts/fusion/vendor/attr/_version_info.pyi new file mode 100644 index 0000000000..45ced08633 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_version_info.pyi @@ -0,0 +1,9 @@ +class VersionInfo: + @property + def year(self) -> int: ... + @property + def minor(self) -> int: ... + @property + def micro(self) -> int: ... + @property + def releaselevel(self) -> str: ... diff --git a/openpype/hosts/fusion/vendor/attr/converters.py b/openpype/hosts/fusion/vendor/attr/converters.py new file mode 100644 index 0000000000..2777db6d0a --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/converters.py @@ -0,0 +1,111 @@ +""" +Commonly useful converters. +""" + +from __future__ import absolute_import, division, print_function + +from ._compat import PY2 +from ._make import NOTHING, Factory, pipe + + +if not PY2: + import inspect + import typing + + +__all__ = [ + "pipe", + "optional", + "default_if_none", +] + + +def optional(converter): + """ + A converter that allows an attribute to be optional. An optional attribute + is one which can be set to ``None``. + + Type annotations will be inferred from the wrapped converter's, if it + has any. + + :param callable converter: the converter that is used for non-``None`` + values. + + .. versionadded:: 17.1.0 + """ + + def optional_converter(val): + if val is None: + return None + return converter(val) + + if not PY2: + sig = None + try: + sig = inspect.signature(converter) + except (ValueError, TypeError): # inspect failed + pass + if sig: + params = list(sig.parameters.values()) + if params and params[0].annotation is not inspect.Parameter.empty: + optional_converter.__annotations__["val"] = typing.Optional[ + params[0].annotation + ] + if sig.return_annotation is not inspect.Signature.empty: + optional_converter.__annotations__["return"] = typing.Optional[ + sig.return_annotation + ] + + return optional_converter + + +def default_if_none(default=NOTHING, factory=None): + """ + A converter that allows to replace ``None`` values by *default* or the + result of *factory*. + + :param default: Value to be used if ``None`` is passed. Passing an instance + of `attr.Factory` is supported, however the ``takes_self`` option + is *not*. + :param callable factory: A callable that takes no parameters whose result + is used if ``None`` is passed. + + :raises TypeError: If **neither** *default* or *factory* is passed. + :raises TypeError: If **both** *default* and *factory* are passed. + :raises ValueError: If an instance of `attr.Factory` is passed with + ``takes_self=True``. + + .. versionadded:: 18.2.0 + """ + if default is NOTHING and factory is None: + raise TypeError("Must pass either `default` or `factory`.") + + if default is not NOTHING and factory is not None: + raise TypeError( + "Must pass either `default` or `factory` but not both." + ) + + if factory is not None: + default = Factory(factory) + + if isinstance(default, Factory): + if default.takes_self: + raise ValueError( + "`takes_self` is not supported by default_if_none." + ) + + def default_if_none_converter(val): + if val is not None: + return val + + return default.factory() + + else: + + def default_if_none_converter(val): + if val is not None: + return val + + return default + + return default_if_none_converter diff --git a/openpype/hosts/fusion/vendor/attr/converters.pyi b/openpype/hosts/fusion/vendor/attr/converters.pyi new file mode 100644 index 0000000000..84a57590b0 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/converters.pyi @@ -0,0 +1,13 @@ +from typing import Callable, Optional, TypeVar, overload + +from . import _ConverterType + + +_T = TypeVar("_T") + +def pipe(*validators: _ConverterType) -> _ConverterType: ... +def optional(converter: _ConverterType) -> _ConverterType: ... +@overload +def default_if_none(default: _T) -> _ConverterType: ... +@overload +def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ... diff --git a/openpype/hosts/fusion/vendor/attr/exceptions.py b/openpype/hosts/fusion/vendor/attr/exceptions.py new file mode 100644 index 0000000000..f6f9861bea --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/exceptions.py @@ -0,0 +1,92 @@ +from __future__ import absolute_import, division, print_function + + +class FrozenError(AttributeError): + """ + A frozen/immutable instance or attribute have been attempted to be + modified. + + It mirrors the behavior of ``namedtuples`` by using the same error message + and subclassing `AttributeError`. + + .. versionadded:: 20.1.0 + """ + + msg = "can't set attribute" + args = [msg] + + +class FrozenInstanceError(FrozenError): + """ + A frozen instance has been attempted to be modified. + + .. versionadded:: 16.1.0 + """ + + +class FrozenAttributeError(FrozenError): + """ + A frozen attribute has been attempted to be modified. + + .. versionadded:: 20.1.0 + """ + + +class AttrsAttributeNotFoundError(ValueError): + """ + An ``attrs`` function couldn't find an attribute that the user asked for. + + .. versionadded:: 16.2.0 + """ + + +class NotAnAttrsClassError(ValueError): + """ + A non-``attrs`` class has been passed into an ``attrs`` function. + + .. versionadded:: 16.2.0 + """ + + +class DefaultAlreadySetError(RuntimeError): + """ + A default has been set using ``attr.ib()`` and is attempted to be reset + using the decorator. + + .. versionadded:: 17.1.0 + """ + + +class UnannotatedAttributeError(RuntimeError): + """ + A class with ``auto_attribs=True`` has an ``attr.ib()`` without a type + annotation. + + .. versionadded:: 17.3.0 + """ + + +class PythonTooOldError(RuntimeError): + """ + It was attempted to use an ``attrs`` feature that requires a newer Python + version. + + .. versionadded:: 18.2.0 + """ + + +class NotCallableError(TypeError): + """ + A ``attr.ib()`` requiring a callable has been set with a value + that is not callable. + + .. versionadded:: 19.2.0 + """ + + def __init__(self, msg, value): + super(TypeError, self).__init__(msg, value) + self.msg = msg + self.value = value + + def __str__(self): + return str(self.msg) diff --git a/openpype/hosts/fusion/vendor/attr/exceptions.pyi b/openpype/hosts/fusion/vendor/attr/exceptions.pyi new file mode 100644 index 0000000000..a800fb26bb --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/exceptions.pyi @@ -0,0 +1,18 @@ +from typing import Any + + +class FrozenError(AttributeError): + msg: str = ... + +class FrozenInstanceError(FrozenError): ... +class FrozenAttributeError(FrozenError): ... +class AttrsAttributeNotFoundError(ValueError): ... +class NotAnAttrsClassError(ValueError): ... +class DefaultAlreadySetError(RuntimeError): ... +class UnannotatedAttributeError(RuntimeError): ... +class PythonTooOldError(RuntimeError): ... + +class NotCallableError(TypeError): + msg: str = ... + value: Any = ... + def __init__(self, msg: str, value: Any) -> None: ... diff --git a/openpype/hosts/fusion/vendor/attr/filters.py b/openpype/hosts/fusion/vendor/attr/filters.py new file mode 100644 index 0000000000..dc47e8fa38 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/filters.py @@ -0,0 +1,52 @@ +""" +Commonly useful filters for `attr.asdict`. +""" + +from __future__ import absolute_import, division, print_function + +from ._compat import isclass +from ._make import Attribute + + +def _split_what(what): + """ + Returns a tuple of `frozenset`s of classes and attributes. + """ + return ( + frozenset(cls for cls in what if isclass(cls)), + frozenset(cls for cls in what if isinstance(cls, Attribute)), + ) + + +def include(*what): + """ + Whitelist *what*. + + :param what: What to whitelist. + :type what: `list` of `type` or `attr.Attribute`\\ s + + :rtype: `callable` + """ + cls, attrs = _split_what(what) + + def include_(attribute, value): + return value.__class__ in cls or attribute in attrs + + return include_ + + +def exclude(*what): + """ + Blacklist *what*. + + :param what: What to blacklist. + :type what: `list` of classes or `attr.Attribute`\\ s. + + :rtype: `callable` + """ + cls, attrs = _split_what(what) + + def exclude_(attribute, value): + return value.__class__ not in cls and attribute not in attrs + + return exclude_ diff --git a/openpype/hosts/fusion/vendor/attr/filters.pyi b/openpype/hosts/fusion/vendor/attr/filters.pyi new file mode 100644 index 0000000000..f7b63f1bb4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/filters.pyi @@ -0,0 +1,7 @@ +from typing import Any, Union + +from . import Attribute, _FilterType + + +def include(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... +def exclude(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... diff --git a/openpype/hosts/fusion/vendor/attr/py.typed b/openpype/hosts/fusion/vendor/attr/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/attr/setters.py b/openpype/hosts/fusion/vendor/attr/setters.py new file mode 100644 index 0000000000..240014b3c1 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/setters.py @@ -0,0 +1,77 @@ +""" +Commonly used hooks for on_setattr. +""" + +from __future__ import absolute_import, division, print_function + +from . import _config +from .exceptions import FrozenAttributeError + + +def pipe(*setters): + """ + Run all *setters* and return the return value of the last one. + + .. versionadded:: 20.1.0 + """ + + def wrapped_pipe(instance, attrib, new_value): + rv = new_value + + for setter in setters: + rv = setter(instance, attrib, rv) + + return rv + + return wrapped_pipe + + +def frozen(_, __, ___): + """ + Prevent an attribute to be modified. + + .. versionadded:: 20.1.0 + """ + raise FrozenAttributeError() + + +def validate(instance, attrib, new_value): + """ + Run *attrib*'s validator on *new_value* if it has one. + + .. versionadded:: 20.1.0 + """ + if _config._run_validators is False: + return new_value + + v = attrib.validator + if not v: + return new_value + + v(instance, attrib, new_value) + + return new_value + + +def convert(instance, attrib, new_value): + """ + Run *attrib*'s converter -- if it has one -- on *new_value* and return the + result. + + .. versionadded:: 20.1.0 + """ + c = attrib.converter + if c: + return c(new_value) + + return new_value + + +NO_OP = object() +""" +Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. + +Does not work in `pipe` or within lists. + +.. versionadded:: 20.1.0 +""" diff --git a/openpype/hosts/fusion/vendor/attr/setters.pyi b/openpype/hosts/fusion/vendor/attr/setters.pyi new file mode 100644 index 0000000000..a921e07deb --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/setters.pyi @@ -0,0 +1,20 @@ +from typing import Any, NewType, NoReturn, TypeVar, cast + +from . import Attribute, _OnSetAttrType + + +_T = TypeVar("_T") + +def frozen( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> NoReturn: ... +def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... +def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... + +# convert is allowed to return Any, because they can be chained using pipe. +def convert( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> Any: ... + +_NoOpType = NewType("_NoOpType", object) +NO_OP: _NoOpType diff --git a/openpype/hosts/fusion/vendor/attr/validators.py b/openpype/hosts/fusion/vendor/attr/validators.py new file mode 100644 index 0000000000..b9a73054e9 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/validators.py @@ -0,0 +1,379 @@ +""" +Commonly useful validators. +""" + +from __future__ import absolute_import, division, print_function + +import re + +from ._make import _AndValidator, and_, attrib, attrs +from .exceptions import NotCallableError + + +__all__ = [ + "and_", + "deep_iterable", + "deep_mapping", + "in_", + "instance_of", + "is_callable", + "matches_re", + "optional", + "provides", +] + + +@attrs(repr=False, slots=True, hash=True) +class _InstanceOfValidator(object): + type = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not isinstance(value, self.type): + raise TypeError( + "'{name}' must be {type!r} (got {value!r} that is a " + "{actual!r}).".format( + name=attr.name, + type=self.type, + actual=value.__class__, + value=value, + ), + attr, + self.type, + value, + ) + + def __repr__(self): + return "".format( + type=self.type + ) + + +def instance_of(type): + """ + A validator that raises a `TypeError` if the initializer is called + with a wrong type for this particular attribute (checks are performed using + `isinstance` therefore it's also valid to pass a tuple of types). + + :param type: The type to check for. + :type type: type or tuple of types + + :raises TypeError: With a human readable error message, the attribute + (of type `attr.Attribute`), the expected type, and the value it + got. + """ + return _InstanceOfValidator(type) + + +@attrs(repr=False, frozen=True, slots=True) +class _MatchesReValidator(object): + regex = attrib() + flags = attrib() + match_func = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.match_func(value): + raise ValueError( + "'{name}' must match regex {regex!r}" + " ({value!r} doesn't)".format( + name=attr.name, regex=self.regex.pattern, value=value + ), + attr, + self.regex, + value, + ) + + def __repr__(self): + return "".format( + regex=self.regex + ) + + +def matches_re(regex, flags=0, func=None): + r""" + A validator that raises `ValueError` if the initializer is called + with a string that doesn't match *regex*. + + :param str regex: a regex string to match against + :param int flags: flags that will be passed to the underlying re function + (default 0) + :param callable func: which underlying `re` function to call (options + are `re.fullmatch`, `re.search`, `re.match`, default + is ``None`` which means either `re.fullmatch` or an emulation of + it on Python 2). For performance reasons, they won't be used directly + but on a pre-`re.compile`\ ed pattern. + + .. versionadded:: 19.2.0 + """ + fullmatch = getattr(re, "fullmatch", None) + valid_funcs = (fullmatch, None, re.search, re.match) + if func not in valid_funcs: + raise ValueError( + "'func' must be one of %s." + % ( + ", ".join( + sorted( + e and e.__name__ or "None" for e in set(valid_funcs) + ) + ), + ) + ) + + pattern = re.compile(regex, flags) + if func is re.match: + match_func = pattern.match + elif func is re.search: + match_func = pattern.search + else: + if fullmatch: + match_func = pattern.fullmatch + else: + pattern = re.compile(r"(?:{})\Z".format(regex), flags) + match_func = pattern.match + + return _MatchesReValidator(pattern, flags, match_func) + + +@attrs(repr=False, slots=True, hash=True) +class _ProvidesValidator(object): + interface = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.interface.providedBy(value): + raise TypeError( + "'{name}' must provide {interface!r} which {value!r} " + "doesn't.".format( + name=attr.name, interface=self.interface, value=value + ), + attr, + self.interface, + value, + ) + + def __repr__(self): + return "".format( + interface=self.interface + ) + + +def provides(interface): + """ + A validator that raises a `TypeError` if the initializer is called + with an object that does not provide the requested *interface* (checks are + performed using ``interface.providedBy(value)`` (see `zope.interface + `_). + + :param interface: The interface to check for. + :type interface: ``zope.interface.Interface`` + + :raises TypeError: With a human readable error message, the attribute + (of type `attr.Attribute`), the expected interface, and the + value it got. + """ + return _ProvidesValidator(interface) + + +@attrs(repr=False, slots=True, hash=True) +class _OptionalValidator(object): + validator = attrib() + + def __call__(self, inst, attr, value): + if value is None: + return + + self.validator(inst, attr, value) + + def __repr__(self): + return "".format( + what=repr(self.validator) + ) + + +def optional(validator): + """ + A validator that makes an attribute optional. An optional attribute is one + which can be set to ``None`` in addition to satisfying the requirements of + the sub-validator. + + :param validator: A validator (or a list of validators) that is used for + non-``None`` values. + :type validator: callable or `list` of callables. + + .. versionadded:: 15.1.0 + .. versionchanged:: 17.1.0 *validator* can be a list of validators. + """ + if isinstance(validator, list): + return _OptionalValidator(_AndValidator(validator)) + return _OptionalValidator(validator) + + +@attrs(repr=False, slots=True, hash=True) +class _InValidator(object): + options = attrib() + + def __call__(self, inst, attr, value): + try: + in_options = value in self.options + except TypeError: # e.g. `1 in "abc"` + in_options = False + + if not in_options: + raise ValueError( + "'{name}' must be in {options!r} (got {value!r})".format( + name=attr.name, options=self.options, value=value + ) + ) + + def __repr__(self): + return "".format( + options=self.options + ) + + +def in_(options): + """ + A validator that raises a `ValueError` if the initializer is called + with a value that does not belong in the options provided. The check is + performed using ``value in options``. + + :param options: Allowed options. + :type options: list, tuple, `enum.Enum`, ... + + :raises ValueError: With a human readable error message, the attribute (of + type `attr.Attribute`), the expected options, and the value it + got. + + .. versionadded:: 17.1.0 + """ + return _InValidator(options) + + +@attrs(repr=False, slots=False, hash=True) +class _IsCallableValidator(object): + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not callable(value): + message = ( + "'{name}' must be callable " + "(got {value!r} that is a {actual!r})." + ) + raise NotCallableError( + msg=message.format( + name=attr.name, value=value, actual=value.__class__ + ), + value=value, + ) + + def __repr__(self): + return "" + + +def is_callable(): + """ + A validator that raises a `attr.exceptions.NotCallableError` if the + initializer is called with a value for this particular attribute + that is not callable. + + .. versionadded:: 19.1.0 + + :raises `attr.exceptions.NotCallableError`: With a human readable error + message containing the attribute (`attr.Attribute`) name, + and the value it got. + """ + return _IsCallableValidator() + + +@attrs(repr=False, slots=True, hash=True) +class _DeepIterable(object): + member_validator = attrib(validator=is_callable()) + iterable_validator = attrib( + default=None, validator=optional(is_callable()) + ) + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if self.iterable_validator is not None: + self.iterable_validator(inst, attr, value) + + for member in value: + self.member_validator(inst, attr, member) + + def __repr__(self): + iterable_identifier = ( + "" + if self.iterable_validator is None + else " {iterable!r}".format(iterable=self.iterable_validator) + ) + return ( + "" + ).format( + iterable_identifier=iterable_identifier, + member=self.member_validator, + ) + + +def deep_iterable(member_validator, iterable_validator=None): + """ + A validator that performs deep validation of an iterable. + + :param member_validator: Validator to apply to iterable members + :param iterable_validator: Validator to apply to iterable itself + (optional) + + .. versionadded:: 19.1.0 + + :raises TypeError: if any sub-validators fail + """ + return _DeepIterable(member_validator, iterable_validator) + + +@attrs(repr=False, slots=True, hash=True) +class _DeepMapping(object): + key_validator = attrib(validator=is_callable()) + value_validator = attrib(validator=is_callable()) + mapping_validator = attrib(default=None, validator=optional(is_callable())) + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if self.mapping_validator is not None: + self.mapping_validator(inst, attr, value) + + for key in value: + self.key_validator(inst, attr, key) + self.value_validator(inst, attr, value[key]) + + def __repr__(self): + return ( + "" + ).format(key=self.key_validator, value=self.value_validator) + + +def deep_mapping(key_validator, value_validator, mapping_validator=None): + """ + A validator that performs deep validation of a dictionary. + + :param key_validator: Validator to apply to dictionary keys + :param value_validator: Validator to apply to dictionary values + :param mapping_validator: Validator to apply to top-level mapping + attribute (optional) + + .. versionadded:: 19.1.0 + + :raises TypeError: if any sub-validators fail + """ + return _DeepMapping(key_validator, value_validator, mapping_validator) diff --git a/openpype/hosts/fusion/vendor/attr/validators.pyi b/openpype/hosts/fusion/vendor/attr/validators.pyi new file mode 100644 index 0000000000..fe92aac421 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/validators.pyi @@ -0,0 +1,68 @@ +from typing import ( + Any, + AnyStr, + Callable, + Container, + Iterable, + List, + Mapping, + Match, + Optional, + Tuple, + Type, + TypeVar, + Union, + overload, +) + +from . import _ValidatorType + + +_T = TypeVar("_T") +_T1 = TypeVar("_T1") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") +_I = TypeVar("_I", bound=Iterable) +_K = TypeVar("_K") +_V = TypeVar("_V") +_M = TypeVar("_M", bound=Mapping) + +# To be more precise on instance_of use some overloads. +# If there are more than 3 items in the tuple then we fall back to Any +@overload +def instance_of(type: Type[_T]) -> _ValidatorType[_T]: ... +@overload +def instance_of(type: Tuple[Type[_T]]) -> _ValidatorType[_T]: ... +@overload +def instance_of( + type: Tuple[Type[_T1], Type[_T2]] +) -> _ValidatorType[Union[_T1, _T2]]: ... +@overload +def instance_of( + type: Tuple[Type[_T1], Type[_T2], Type[_T3]] +) -> _ValidatorType[Union[_T1, _T2, _T3]]: ... +@overload +def instance_of(type: Tuple[type, ...]) -> _ValidatorType[Any]: ... +def provides(interface: Any) -> _ValidatorType[Any]: ... +def optional( + validator: Union[_ValidatorType[_T], List[_ValidatorType[_T]]] +) -> _ValidatorType[Optional[_T]]: ... +def in_(options: Container[_T]) -> _ValidatorType[_T]: ... +def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... +def matches_re( + regex: AnyStr, + flags: int = ..., + func: Optional[ + Callable[[AnyStr, AnyStr, int], Optional[Match[AnyStr]]] + ] = ..., +) -> _ValidatorType[AnyStr]: ... +def deep_iterable( + member_validator: _ValidatorType[_T], + iterable_validator: Optional[_ValidatorType[_I]] = ..., +) -> _ValidatorType[_I]: ... +def deep_mapping( + key_validator: _ValidatorType[_K], + value_validator: _ValidatorType[_V], + mapping_validator: Optional[_ValidatorType[_M]] = ..., +) -> _ValidatorType[_M]: ... +def is_callable() -> _ValidatorType[_T]: ... diff --git a/openpype/hosts/fusion/vendor/urllib3/__init__.py b/openpype/hosts/fusion/vendor/urllib3/__init__.py new file mode 100644 index 0000000000..fe86b59d78 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/__init__.py @@ -0,0 +1,85 @@ +""" +Python HTTP library with thread-safe connection pooling, file post support, user friendly, and more +""" +from __future__ import absolute_import + +# Set default logging handler to avoid "No handler found" warnings. +import logging +import warnings +from logging import NullHandler + +from . import exceptions +from ._version import __version__ +from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, connection_from_url +from .filepost import encode_multipart_formdata +from .poolmanager import PoolManager, ProxyManager, proxy_from_url +from .response import HTTPResponse +from .util.request import make_headers +from .util.retry import Retry +from .util.timeout import Timeout +from .util.url import get_host + +__author__ = "Andrey Petrov (andrey.petrov@shazow.net)" +__license__ = "MIT" +__version__ = __version__ + +__all__ = ( + "HTTPConnectionPool", + "HTTPSConnectionPool", + "PoolManager", + "ProxyManager", + "HTTPResponse", + "Retry", + "Timeout", + "add_stderr_logger", + "connection_from_url", + "disable_warnings", + "encode_multipart_formdata", + "get_host", + "make_headers", + "proxy_from_url", +) + +logging.getLogger(__name__).addHandler(NullHandler()) + + +def add_stderr_logger(level=logging.DEBUG): + """ + Helper for quickly adding a StreamHandler to the logger. Useful for + debugging. + + Returns the handler after adding it. + """ + # This method needs to be in this __init__.py to get the __name__ correct + # even if urllib3 is vendored within another package. + logger = logging.getLogger(__name__) + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + logger.addHandler(handler) + logger.setLevel(level) + logger.debug("Added a stderr logging handler to logger: %s", __name__) + return handler + + +# ... Clean up. +del NullHandler + + +# All warning filters *must* be appended unless you're really certain that they +# shouldn't be: otherwise, it's very hard for users to use most Python +# mechanisms to silence them. +# SecurityWarning's always go off by default. +warnings.simplefilter("always", exceptions.SecurityWarning, append=True) +# SubjectAltNameWarning's should go off once per host +warnings.simplefilter("default", exceptions.SubjectAltNameWarning, append=True) +# InsecurePlatformWarning's don't vary between requests, so we keep it default. +warnings.simplefilter("default", exceptions.InsecurePlatformWarning, append=True) +# SNIMissingWarnings should go off only once. +warnings.simplefilter("default", exceptions.SNIMissingWarning, append=True) + + +def disable_warnings(category=exceptions.HTTPWarning): + """ + Helper for quickly disabling all urllib3 warnings. + """ + warnings.simplefilter("ignore", category) diff --git a/openpype/hosts/fusion/vendor/urllib3/_collections.py b/openpype/hosts/fusion/vendor/urllib3/_collections.py new file mode 100644 index 0000000000..da9857e986 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/_collections.py @@ -0,0 +1,337 @@ +from __future__ import absolute_import + +try: + from collections.abc import Mapping, MutableMapping +except ImportError: + from collections import Mapping, MutableMapping +try: + from threading import RLock +except ImportError: # Platform-specific: No threads available + + class RLock: + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + pass + + +from collections import OrderedDict + +from .exceptions import InvalidHeader +from .packages import six +from .packages.six import iterkeys, itervalues + +__all__ = ["RecentlyUsedContainer", "HTTPHeaderDict"] + + +_Null = object() + + +class RecentlyUsedContainer(MutableMapping): + """ + Provides a thread-safe dict-like container which maintains up to + ``maxsize`` keys while throwing away the least-recently-used keys beyond + ``maxsize``. + + :param maxsize: + Maximum number of recent elements to retain. + + :param dispose_func: + Every time an item is evicted from the container, + ``dispose_func(value)`` is called. Callback which will get called + """ + + ContainerCls = OrderedDict + + def __init__(self, maxsize=10, dispose_func=None): + self._maxsize = maxsize + self.dispose_func = dispose_func + + self._container = self.ContainerCls() + self.lock = RLock() + + def __getitem__(self, key): + # Re-insert the item, moving it to the end of the eviction line. + with self.lock: + item = self._container.pop(key) + self._container[key] = item + return item + + def __setitem__(self, key, value): + evicted_value = _Null + with self.lock: + # Possibly evict the existing value of 'key' + evicted_value = self._container.get(key, _Null) + self._container[key] = value + + # If we didn't evict an existing value, we might have to evict the + # least recently used item from the beginning of the container. + if len(self._container) > self._maxsize: + _key, evicted_value = self._container.popitem(last=False) + + if self.dispose_func and evicted_value is not _Null: + self.dispose_func(evicted_value) + + def __delitem__(self, key): + with self.lock: + value = self._container.pop(key) + + if self.dispose_func: + self.dispose_func(value) + + def __len__(self): + with self.lock: + return len(self._container) + + def __iter__(self): + raise NotImplementedError( + "Iteration over this class is unlikely to be threadsafe." + ) + + def clear(self): + with self.lock: + # Copy pointers to all values, then wipe the mapping + values = list(itervalues(self._container)) + self._container.clear() + + if self.dispose_func: + for value in values: + self.dispose_func(value) + + def keys(self): + with self.lock: + return list(iterkeys(self._container)) + + +class HTTPHeaderDict(MutableMapping): + """ + :param headers: + An iterable of field-value pairs. Must not contain multiple field names + when compared case-insensitively. + + :param kwargs: + Additional field-value pairs to pass in to ``dict.update``. + + A ``dict`` like container for storing HTTP Headers. + + Field names are stored and compared case-insensitively in compliance with + RFC 7230. Iteration provides the first case-sensitive key seen for each + case-insensitive pair. + + Using ``__setitem__`` syntax overwrites fields that compare equal + case-insensitively in order to maintain ``dict``'s api. For fields that + compare equal, instead create a new ``HTTPHeaderDict`` and use ``.add`` + in a loop. + + If multiple fields that are equal case-insensitively are passed to the + constructor or ``.update``, the behavior is undefined and some will be + lost. + + >>> headers = HTTPHeaderDict() + >>> headers.add('Set-Cookie', 'foo=bar') + >>> headers.add('set-cookie', 'baz=quxx') + >>> headers['content-length'] = '7' + >>> headers['SET-cookie'] + 'foo=bar, baz=quxx' + >>> headers['Content-Length'] + '7' + """ + + def __init__(self, headers=None, **kwargs): + super(HTTPHeaderDict, self).__init__() + self._container = OrderedDict() + if headers is not None: + if isinstance(headers, HTTPHeaderDict): + self._copy_from(headers) + else: + self.extend(headers) + if kwargs: + self.extend(kwargs) + + def __setitem__(self, key, val): + self._container[key.lower()] = [key, val] + return self._container[key.lower()] + + def __getitem__(self, key): + val = self._container[key.lower()] + return ", ".join(val[1:]) + + def __delitem__(self, key): + del self._container[key.lower()] + + def __contains__(self, key): + return key.lower() in self._container + + def __eq__(self, other): + if not isinstance(other, Mapping) and not hasattr(other, "keys"): + return False + if not isinstance(other, type(self)): + other = type(self)(other) + return dict((k.lower(), v) for k, v in self.itermerged()) == dict( + (k.lower(), v) for k, v in other.itermerged() + ) + + def __ne__(self, other): + return not self.__eq__(other) + + if six.PY2: # Python 2 + iterkeys = MutableMapping.iterkeys + itervalues = MutableMapping.itervalues + + __marker = object() + + def __len__(self): + return len(self._container) + + def __iter__(self): + # Only provide the originally cased names + for vals in self._container.values(): + yield vals[0] + + def pop(self, key, default=__marker): + """D.pop(k[,d]) -> v, remove specified key and return the corresponding value. + If key is not found, d is returned if given, otherwise KeyError is raised. + """ + # Using the MutableMapping function directly fails due to the private marker. + # Using ordinary dict.pop would expose the internal structures. + # So let's reinvent the wheel. + try: + value = self[key] + except KeyError: + if default is self.__marker: + raise + return default + else: + del self[key] + return value + + def discard(self, key): + try: + del self[key] + except KeyError: + pass + + def add(self, key, val): + """Adds a (name, value) pair, doesn't overwrite the value if it already + exists. + + >>> headers = HTTPHeaderDict(foo='bar') + >>> headers.add('Foo', 'baz') + >>> headers['foo'] + 'bar, baz' + """ + key_lower = key.lower() + new_vals = [key, val] + # Keep the common case aka no item present as fast as possible + vals = self._container.setdefault(key_lower, new_vals) + if new_vals is not vals: + vals.append(val) + + def extend(self, *args, **kwargs): + """Generic import function for any type of header-like object. + Adapted version of MutableMapping.update in order to insert items + with self.add instead of self.__setitem__ + """ + if len(args) > 1: + raise TypeError( + "extend() takes at most 1 positional " + "arguments ({0} given)".format(len(args)) + ) + other = args[0] if len(args) >= 1 else () + + if isinstance(other, HTTPHeaderDict): + for key, val in other.iteritems(): + self.add(key, val) + elif isinstance(other, Mapping): + for key in other: + self.add(key, other[key]) + elif hasattr(other, "keys"): + for key in other.keys(): + self.add(key, other[key]) + else: + for key, value in other: + self.add(key, value) + + for key, value in kwargs.items(): + self.add(key, value) + + def getlist(self, key, default=__marker): + """Returns a list of all the values for the named field. Returns an + empty list if the key doesn't exist.""" + try: + vals = self._container[key.lower()] + except KeyError: + if default is self.__marker: + return [] + return default + else: + return vals[1:] + + # Backwards compatibility for httplib + getheaders = getlist + getallmatchingheaders = getlist + iget = getlist + + # Backwards compatibility for http.cookiejar + get_all = getlist + + def __repr__(self): + return "%s(%s)" % (type(self).__name__, dict(self.itermerged())) + + def _copy_from(self, other): + for key in other: + val = other.getlist(key) + if isinstance(val, list): + # Don't need to convert tuples + val = list(val) + self._container[key.lower()] = [key] + val + + def copy(self): + clone = type(self)() + clone._copy_from(self) + return clone + + def iteritems(self): + """Iterate over all header lines, including duplicate ones.""" + for key in self: + vals = self._container[key.lower()] + for val in vals[1:]: + yield vals[0], val + + def itermerged(self): + """Iterate over all headers, merging duplicate ones together.""" + for key in self: + val = self._container[key.lower()] + yield val[0], ", ".join(val[1:]) + + def items(self): + return list(self.iteritems()) + + @classmethod + def from_httplib(cls, message): # Python 2 + """Read headers from a Python 2 httplib message object.""" + # python2.7 does not expose a proper API for exporting multiheaders + # efficiently. This function re-reads raw lines from the message + # object and extracts the multiheaders properly. + obs_fold_continued_leaders = (" ", "\t") + headers = [] + + for line in message.headers: + if line.startswith(obs_fold_continued_leaders): + if not headers: + # We received a header line that starts with OWS as described + # in RFC-7230 S3.2.4. This indicates a multiline header, but + # there exists no previous header to which we can attach it. + raise InvalidHeader( + "Header continuation with no previous header: %s" % line + ) + else: + key, value = headers[-1] + headers[-1] = (key, value + " " + line.strip()) + continue + + key, value = line.split(":", 1) + headers.append((key, value.strip())) + + return cls(headers) diff --git a/openpype/hosts/fusion/vendor/urllib3/_version.py b/openpype/hosts/fusion/vendor/urllib3/_version.py new file mode 100644 index 0000000000..e8ebee957f --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/_version.py @@ -0,0 +1,2 @@ +# This file is protected via CODEOWNERS +__version__ = "1.26.6" diff --git a/openpype/hosts/fusion/vendor/urllib3/connection.py b/openpype/hosts/fusion/vendor/urllib3/connection.py new file mode 100644 index 0000000000..4c996659c8 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/connection.py @@ -0,0 +1,539 @@ +from __future__ import absolute_import + +import datetime +import logging +import os +import re +import socket +import warnings +from socket import error as SocketError +from socket import timeout as SocketTimeout + +from .packages import six +from .packages.six.moves.http_client import HTTPConnection as _HTTPConnection +from .packages.six.moves.http_client import HTTPException # noqa: F401 +from .util.proxy import create_proxy_ssl_context + +try: # Compiled with SSL? + import ssl + + BaseSSLError = ssl.SSLError +except (ImportError, AttributeError): # Platform-specific: No SSL. + ssl = None + + class BaseSSLError(BaseException): + pass + + +try: + # Python 3: not a no-op, we're adding this to the namespace so it can be imported. + ConnectionError = ConnectionError +except NameError: + # Python 2 + class ConnectionError(Exception): + pass + + +try: # Python 3: + # Not a no-op, we're adding this to the namespace so it can be imported. + BrokenPipeError = BrokenPipeError +except NameError: # Python 2: + + class BrokenPipeError(Exception): + pass + + +from ._collections import HTTPHeaderDict # noqa (historical, removed in v2) +from ._version import __version__ +from .exceptions import ( + ConnectTimeoutError, + NewConnectionError, + SubjectAltNameWarning, + SystemTimeWarning, +) +from .packages.ssl_match_hostname import CertificateError, match_hostname +from .util import SKIP_HEADER, SKIPPABLE_HEADERS, connection +from .util.ssl_ import ( + assert_fingerprint, + create_urllib3_context, + resolve_cert_reqs, + resolve_ssl_version, + ssl_wrap_socket, +) + +log = logging.getLogger(__name__) + +port_by_scheme = {"http": 80, "https": 443} + +# When it comes time to update this value as a part of regular maintenance +# (ie test_recent_date is failing) update it to ~6 months before the current date. +RECENT_DATE = datetime.date(2020, 7, 1) + +_CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]") + + +class HTTPConnection(_HTTPConnection, object): + """ + Based on :class:`http.client.HTTPConnection` but provides an extra constructor + backwards-compatibility layer between older and newer Pythons. + + Additional keyword parameters are used to configure attributes of the connection. + Accepted parameters include: + + - ``strict``: See the documentation on :class:`urllib3.connectionpool.HTTPConnectionPool` + - ``source_address``: Set the source address for the current connection. + - ``socket_options``: Set specific options on the underlying socket. If not specified, then + defaults are loaded from ``HTTPConnection.default_socket_options`` which includes disabling + Nagle's algorithm (sets TCP_NODELAY to 1) unless the connection is behind a proxy. + + For example, if you wish to enable TCP Keep Alive in addition to the defaults, + you might pass: + + .. code-block:: python + + HTTPConnection.default_socket_options + [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ] + + Or you may want to disable the defaults by passing an empty list (e.g., ``[]``). + """ + + default_port = port_by_scheme["http"] + + #: Disable Nagle's algorithm by default. + #: ``[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]`` + default_socket_options = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)] + + #: Whether this connection verifies the host's certificate. + is_verified = False + + def __init__(self, *args, **kw): + if not six.PY2: + kw.pop("strict", None) + + # Pre-set source_address. + self.source_address = kw.get("source_address") + + #: The socket options provided by the user. If no options are + #: provided, we use the default options. + self.socket_options = kw.pop("socket_options", self.default_socket_options) + + # Proxy options provided by the user. + self.proxy = kw.pop("proxy", None) + self.proxy_config = kw.pop("proxy_config", None) + + _HTTPConnection.__init__(self, *args, **kw) + + @property + def host(self): + """ + Getter method to remove any trailing dots that indicate the hostname is an FQDN. + + In general, SSL certificates don't include the trailing dot indicating a + fully-qualified domain name, and thus, they don't validate properly when + checked against a domain name that includes the dot. In addition, some + servers may not expect to receive the trailing dot when provided. + + However, the hostname with trailing dot is critical to DNS resolution; doing a + lookup with the trailing dot will properly only resolve the appropriate FQDN, + whereas a lookup without a trailing dot will search the system's search domain + list. Thus, it's important to keep the original host around for use only in + those cases where it's appropriate (i.e., when doing DNS lookup to establish the + actual TCP connection across which we're going to send HTTP requests). + """ + return self._dns_host.rstrip(".") + + @host.setter + def host(self, value): + """ + Setter for the `host` property. + + We assume that only urllib3 uses the _dns_host attribute; httplib itself + only uses `host`, and it seems reasonable that other libraries follow suit. + """ + self._dns_host = value + + def _new_conn(self): + """Establish a socket connection and set nodelay settings on it. + + :return: New socket connection. + """ + extra_kw = {} + if self.source_address: + extra_kw["source_address"] = self.source_address + + if self.socket_options: + extra_kw["socket_options"] = self.socket_options + + try: + conn = connection.create_connection( + (self._dns_host, self.port), self.timeout, **extra_kw + ) + + except SocketTimeout: + raise ConnectTimeoutError( + self, + "Connection to %s timed out. (connect timeout=%s)" + % (self.host, self.timeout), + ) + + except SocketError as e: + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % e + ) + + return conn + + def _is_using_tunnel(self): + # Google App Engine's httplib does not define _tunnel_host + return getattr(self, "_tunnel_host", None) + + def _prepare_conn(self, conn): + self.sock = conn + if self._is_using_tunnel(): + # TODO: Fix tunnel so it doesn't depend on self.sock state. + self._tunnel() + # Mark this connection as not reusable + self.auto_open = 0 + + def connect(self): + conn = self._new_conn() + self._prepare_conn(conn) + + def putrequest(self, method, url, *args, **kwargs): + """ """ + # Empty docstring because the indentation of CPython's implementation + # is broken but we don't want this method in our documentation. + match = _CONTAINS_CONTROL_CHAR_RE.search(method) + if match: + raise ValueError( + "Method cannot contain non-token characters %r (found at least %r)" + % (method, match.group()) + ) + + return _HTTPConnection.putrequest(self, method, url, *args, **kwargs) + + def putheader(self, header, *values): + """ """ + if not any(isinstance(v, str) and v == SKIP_HEADER for v in values): + _HTTPConnection.putheader(self, header, *values) + elif six.ensure_str(header.lower()) not in SKIPPABLE_HEADERS: + raise ValueError( + "urllib3.util.SKIP_HEADER only supports '%s'" + % ("', '".join(map(str.title, sorted(SKIPPABLE_HEADERS))),) + ) + + def request(self, method, url, body=None, headers=None): + if headers is None: + headers = {} + else: + # Avoid modifying the headers passed into .request() + headers = headers.copy() + if "user-agent" not in (six.ensure_str(k.lower()) for k in headers): + headers["User-Agent"] = _get_default_user_agent() + super(HTTPConnection, self).request(method, url, body=body, headers=headers) + + def request_chunked(self, method, url, body=None, headers=None): + """ + Alternative to the common request method, which sends the + body with chunked encoding and not as one block + """ + headers = headers or {} + header_keys = set([six.ensure_str(k.lower()) for k in headers]) + skip_accept_encoding = "accept-encoding" in header_keys + skip_host = "host" in header_keys + self.putrequest( + method, url, skip_accept_encoding=skip_accept_encoding, skip_host=skip_host + ) + if "user-agent" not in header_keys: + self.putheader("User-Agent", _get_default_user_agent()) + for header, value in headers.items(): + self.putheader(header, value) + if "transfer-encoding" not in header_keys: + self.putheader("Transfer-Encoding", "chunked") + self.endheaders() + + if body is not None: + stringish_types = six.string_types + (bytes,) + if isinstance(body, stringish_types): + body = (body,) + for chunk in body: + if not chunk: + continue + if not isinstance(chunk, bytes): + chunk = chunk.encode("utf8") + len_str = hex(len(chunk))[2:] + to_send = bytearray(len_str.encode()) + to_send += b"\r\n" + to_send += chunk + to_send += b"\r\n" + self.send(to_send) + + # After the if clause, to always have a closed body + self.send(b"0\r\n\r\n") + + +class HTTPSConnection(HTTPConnection): + """ + Many of the parameters to this constructor are passed to the underlying SSL + socket by means of :py:func:`urllib3.util.ssl_wrap_socket`. + """ + + default_port = port_by_scheme["https"] + + cert_reqs = None + ca_certs = None + ca_cert_dir = None + ca_cert_data = None + ssl_version = None + assert_fingerprint = None + tls_in_tls_required = False + + def __init__( + self, + host, + port=None, + key_file=None, + cert_file=None, + key_password=None, + strict=None, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + ssl_context=None, + server_hostname=None, + **kw + ): + + HTTPConnection.__init__(self, host, port, strict=strict, timeout=timeout, **kw) + + self.key_file = key_file + self.cert_file = cert_file + self.key_password = key_password + self.ssl_context = ssl_context + self.server_hostname = server_hostname + + # Required property for Google AppEngine 1.9.0 which otherwise causes + # HTTPS requests to go out as HTTP. (See Issue #356) + self._protocol = "https" + + def set_cert( + self, + key_file=None, + cert_file=None, + cert_reqs=None, + key_password=None, + ca_certs=None, + assert_hostname=None, + assert_fingerprint=None, + ca_cert_dir=None, + ca_cert_data=None, + ): + """ + This method should only be called once, before the connection is used. + """ + # If cert_reqs is not provided we'll assume CERT_REQUIRED unless we also + # have an SSLContext object in which case we'll use its verify_mode. + if cert_reqs is None: + if self.ssl_context is not None: + cert_reqs = self.ssl_context.verify_mode + else: + cert_reqs = resolve_cert_reqs(None) + + self.key_file = key_file + self.cert_file = cert_file + self.cert_reqs = cert_reqs + self.key_password = key_password + self.assert_hostname = assert_hostname + self.assert_fingerprint = assert_fingerprint + self.ca_certs = ca_certs and os.path.expanduser(ca_certs) + self.ca_cert_dir = ca_cert_dir and os.path.expanduser(ca_cert_dir) + self.ca_cert_data = ca_cert_data + + def connect(self): + # Add certificate verification + conn = self._new_conn() + hostname = self.host + tls_in_tls = False + + if self._is_using_tunnel(): + if self.tls_in_tls_required: + conn = self._connect_tls_proxy(hostname, conn) + tls_in_tls = True + + self.sock = conn + + # Calls self._set_hostport(), so self.host is + # self._tunnel_host below. + self._tunnel() + # Mark this connection as not reusable + self.auto_open = 0 + + # Override the host with the one we're requesting data from. + hostname = self._tunnel_host + + server_hostname = hostname + if self.server_hostname is not None: + server_hostname = self.server_hostname + + is_time_off = datetime.date.today() < RECENT_DATE + if is_time_off: + warnings.warn( + ( + "System time is way off (before {0}). This will probably " + "lead to SSL verification errors" + ).format(RECENT_DATE), + SystemTimeWarning, + ) + + # Wrap socket using verification with the root certs in + # trusted_root_certs + default_ssl_context = False + if self.ssl_context is None: + default_ssl_context = True + self.ssl_context = create_urllib3_context( + ssl_version=resolve_ssl_version(self.ssl_version), + cert_reqs=resolve_cert_reqs(self.cert_reqs), + ) + + context = self.ssl_context + context.verify_mode = resolve_cert_reqs(self.cert_reqs) + + # Try to load OS default certs if none are given. + # Works well on Windows (requires Python3.4+) + if ( + not self.ca_certs + and not self.ca_cert_dir + and not self.ca_cert_data + and default_ssl_context + and hasattr(context, "load_default_certs") + ): + context.load_default_certs() + + self.sock = ssl_wrap_socket( + sock=conn, + keyfile=self.key_file, + certfile=self.cert_file, + key_password=self.key_password, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + ca_cert_data=self.ca_cert_data, + server_hostname=server_hostname, + ssl_context=context, + tls_in_tls=tls_in_tls, + ) + + # If we're using all defaults and the connection + # is TLSv1 or TLSv1.1 we throw a DeprecationWarning + # for the host. + if ( + default_ssl_context + and self.ssl_version is None + and hasattr(self.sock, "version") + and self.sock.version() in {"TLSv1", "TLSv1.1"} + ): + warnings.warn( + "Negotiating TLSv1/TLSv1.1 by default is deprecated " + "and will be disabled in urllib3 v2.0.0. Connecting to " + "'%s' with '%s' can be enabled by explicitly opting-in " + "with 'ssl_version'" % (self.host, self.sock.version()), + DeprecationWarning, + ) + + if self.assert_fingerprint: + assert_fingerprint( + self.sock.getpeercert(binary_form=True), self.assert_fingerprint + ) + elif ( + context.verify_mode != ssl.CERT_NONE + and not getattr(context, "check_hostname", False) + and self.assert_hostname is not False + ): + # While urllib3 attempts to always turn off hostname matching from + # the TLS library, this cannot always be done. So we check whether + # the TLS Library still thinks it's matching hostnames. + cert = self.sock.getpeercert() + if not cert.get("subjectAltName", ()): + warnings.warn( + ( + "Certificate for {0} has no `subjectAltName`, falling back to check for a " + "`commonName` for now. This feature is being removed by major browsers and " + "deprecated by RFC 2818. (See https://github.com/urllib3/urllib3/issues/497 " + "for details.)".format(hostname) + ), + SubjectAltNameWarning, + ) + _match_hostname(cert, self.assert_hostname or server_hostname) + + self.is_verified = ( + context.verify_mode == ssl.CERT_REQUIRED + or self.assert_fingerprint is not None + ) + + def _connect_tls_proxy(self, hostname, conn): + """ + Establish a TLS connection to the proxy using the provided SSL context. + """ + proxy_config = self.proxy_config + ssl_context = proxy_config.ssl_context + if ssl_context: + # If the user provided a proxy context, we assume CA and client + # certificates have already been set + return ssl_wrap_socket( + sock=conn, + server_hostname=hostname, + ssl_context=ssl_context, + ) + + ssl_context = create_proxy_ssl_context( + self.ssl_version, + self.cert_reqs, + self.ca_certs, + self.ca_cert_dir, + self.ca_cert_data, + ) + # By default urllib3's SSLContext disables `check_hostname` and uses + # a custom check. For proxies we're good with relying on the default + # verification. + ssl_context.check_hostname = True + + # If no cert was provided, use only the default options for server + # certificate validation + return ssl_wrap_socket( + sock=conn, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + ca_cert_data=self.ca_cert_data, + server_hostname=hostname, + ssl_context=ssl_context, + ) + + +def _match_hostname(cert, asserted_hostname): + try: + match_hostname(cert, asserted_hostname) + except CertificateError as e: + log.warning( + "Certificate did not match expected hostname: %s. Certificate: %s", + asserted_hostname, + cert, + ) + # Add cert to exception and reraise so client code can inspect + # the cert when catching the exception, if they want to + e._peer_cert = cert + raise + + +def _get_default_user_agent(): + return "python-urllib3/%s" % __version__ + + +class DummyConnection(object): + """Used to detect a failed ConnectionCls import.""" + + pass + + +if not ssl: + HTTPSConnection = DummyConnection # noqa: F811 + + +VerifiedHTTPSConnection = HTTPSConnection diff --git a/openpype/hosts/fusion/vendor/urllib3/connectionpool.py b/openpype/hosts/fusion/vendor/urllib3/connectionpool.py new file mode 100644 index 0000000000..459bbe095b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/connectionpool.py @@ -0,0 +1,1067 @@ +from __future__ import absolute_import + +import errno +import logging +import socket +import sys +import warnings +from socket import error as SocketError +from socket import timeout as SocketTimeout + +from .connection import ( + BaseSSLError, + BrokenPipeError, + DummyConnection, + HTTPConnection, + HTTPException, + HTTPSConnection, + VerifiedHTTPSConnection, + port_by_scheme, +) +from .exceptions import ( + ClosedPoolError, + EmptyPoolError, + HeaderParsingError, + HostChangedError, + InsecureRequestWarning, + LocationValueError, + MaxRetryError, + NewConnectionError, + ProtocolError, + ProxyError, + ReadTimeoutError, + SSLError, + TimeoutError, +) +from .packages import six +from .packages.six.moves import queue +from .packages.ssl_match_hostname import CertificateError +from .request import RequestMethods +from .response import HTTPResponse +from .util.connection import is_connection_dropped +from .util.proxy import connection_requires_http_tunnel +from .util.queue import LifoQueue +from .util.request import set_file_position +from .util.response import assert_header_parsing +from .util.retry import Retry +from .util.timeout import Timeout +from .util.url import Url, _encode_target +from .util.url import _normalize_host as normalize_host +from .util.url import get_host, parse_url + +xrange = six.moves.xrange + +log = logging.getLogger(__name__) + +_Default = object() + + +# Pool objects +class ConnectionPool(object): + """ + Base class for all connection pools, such as + :class:`.HTTPConnectionPool` and :class:`.HTTPSConnectionPool`. + + .. note:: + ConnectionPool.urlopen() does not normalize or percent-encode target URIs + which is useful if your target server doesn't support percent-encoded + target URIs. + """ + + scheme = None + QueueCls = LifoQueue + + def __init__(self, host, port=None): + if not host: + raise LocationValueError("No host specified.") + + self.host = _normalize_host(host, scheme=self.scheme) + self._proxy_host = host.lower() + self.port = port + + def __str__(self): + return "%s(host=%r, port=%r)" % (type(self).__name__, self.host, self.port) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + # Return False to re-raise any potential exceptions + return False + + def close(self): + """ + Close all pooled connections and disable the pool. + """ + pass + + +# This is taken from http://hg.python.org/cpython/file/7aaba721ebc0/Lib/socket.py#l252 +_blocking_errnos = {errno.EAGAIN, errno.EWOULDBLOCK} + + +class HTTPConnectionPool(ConnectionPool, RequestMethods): + """ + Thread-safe connection pool for one host. + + :param host: + Host used for this HTTP Connection (e.g. "localhost"), passed into + :class:`http.client.HTTPConnection`. + + :param port: + Port used for this HTTP Connection (None is equivalent to 80), passed + into :class:`http.client.HTTPConnection`. + + :param strict: + Causes BadStatusLine to be raised if the status line can't be parsed + as a valid HTTP/1.0 or 1.1 status line, passed into + :class:`http.client.HTTPConnection`. + + .. note:: + Only works in Python 2. This parameter is ignored in Python 3. + + :param timeout: + Socket timeout in seconds for each individual connection. This can + be a float or integer, which sets the timeout for the HTTP request, + or an instance of :class:`urllib3.util.Timeout` which gives you more + fine-grained control over request timeouts. After the constructor has + been parsed, this is always a `urllib3.util.Timeout` object. + + :param maxsize: + Number of connections to save that can be reused. More than 1 is useful + in multithreaded situations. If ``block`` is set to False, more + connections will be created but they will not be saved once they've + been used. + + :param block: + If set to True, no more than ``maxsize`` connections will be used at + a time. When no free connections are available, the call will block + until a connection has been released. This is a useful side effect for + particular multithreaded situations where one does not want to use more + than maxsize connections per host to prevent flooding. + + :param headers: + Headers to include with all requests, unless other headers are given + explicitly. + + :param retries: + Retry configuration to use by default with requests in this pool. + + :param _proxy: + Parsed proxy URL, should not be used directly, instead, see + :class:`urllib3.ProxyManager` + + :param _proxy_headers: + A dictionary with proxy headers, should not be used directly, + instead, see :class:`urllib3.ProxyManager` + + :param \\**conn_kw: + Additional parameters are used to create fresh :class:`urllib3.connection.HTTPConnection`, + :class:`urllib3.connection.HTTPSConnection` instances. + """ + + scheme = "http" + ConnectionCls = HTTPConnection + ResponseCls = HTTPResponse + + def __init__( + self, + host, + port=None, + strict=False, + timeout=Timeout.DEFAULT_TIMEOUT, + maxsize=1, + block=False, + headers=None, + retries=None, + _proxy=None, + _proxy_headers=None, + _proxy_config=None, + **conn_kw + ): + ConnectionPool.__init__(self, host, port) + RequestMethods.__init__(self, headers) + + self.strict = strict + + if not isinstance(timeout, Timeout): + timeout = Timeout.from_float(timeout) + + if retries is None: + retries = Retry.DEFAULT + + self.timeout = timeout + self.retries = retries + + self.pool = self.QueueCls(maxsize) + self.block = block + + self.proxy = _proxy + self.proxy_headers = _proxy_headers or {} + self.proxy_config = _proxy_config + + # Fill the queue up so that doing get() on it will block properly + for _ in xrange(maxsize): + self.pool.put(None) + + # These are mostly for testing and debugging purposes. + self.num_connections = 0 + self.num_requests = 0 + self.conn_kw = conn_kw + + if self.proxy: + # Enable Nagle's algorithm for proxies, to avoid packet fragmentation. + # We cannot know if the user has added default socket options, so we cannot replace the + # list. + self.conn_kw.setdefault("socket_options", []) + + self.conn_kw["proxy"] = self.proxy + self.conn_kw["proxy_config"] = self.proxy_config + + def _new_conn(self): + """ + Return a fresh :class:`HTTPConnection`. + """ + self.num_connections += 1 + log.debug( + "Starting new HTTP connection (%d): %s:%s", + self.num_connections, + self.host, + self.port or "80", + ) + + conn = self.ConnectionCls( + host=self.host, + port=self.port, + timeout=self.timeout.connect_timeout, + strict=self.strict, + **self.conn_kw + ) + return conn + + def _get_conn(self, timeout=None): + """ + Get a connection. Will return a pooled connection if one is available. + + If no connections are available and :prop:`.block` is ``False``, then a + fresh connection is returned. + + :param timeout: + Seconds to wait before giving up and raising + :class:`urllib3.exceptions.EmptyPoolError` if the pool is empty and + :prop:`.block` is ``True``. + """ + conn = None + try: + conn = self.pool.get(block=self.block, timeout=timeout) + + except AttributeError: # self.pool is None + raise ClosedPoolError(self, "Pool is closed.") + + except queue.Empty: + if self.block: + raise EmptyPoolError( + self, + "Pool reached maximum size and no more connections are allowed.", + ) + pass # Oh well, we'll create a new connection then + + # If this is a persistent connection, check if it got disconnected + if conn and is_connection_dropped(conn): + log.debug("Resetting dropped connection: %s", self.host) + conn.close() + if getattr(conn, "auto_open", 1) == 0: + # This is a proxied connection that has been mutated by + # http.client._tunnel() and cannot be reused (since it would + # attempt to bypass the proxy) + conn = None + + return conn or self._new_conn() + + def _put_conn(self, conn): + """ + Put a connection back into the pool. + + :param conn: + Connection object for the current host and port as returned by + :meth:`._new_conn` or :meth:`._get_conn`. + + If the pool is already full, the connection is closed and discarded + because we exceeded maxsize. If connections are discarded frequently, + then maxsize should be increased. + + If the pool is closed, then the connection will be closed and discarded. + """ + try: + self.pool.put(conn, block=False) + return # Everything is dandy, done. + except AttributeError: + # self.pool is None. + pass + except queue.Full: + # This should never happen if self.block == True + log.warning("Connection pool is full, discarding connection: %s", self.host) + + # Connection never got put back into the pool, close it. + if conn: + conn.close() + + def _validate_conn(self, conn): + """ + Called right before a request is made, after the socket is created. + """ + pass + + def _prepare_proxy(self, conn): + # Nothing to do for HTTP connections. + pass + + def _get_timeout(self, timeout): + """Helper that always returns a :class:`urllib3.util.Timeout`""" + if timeout is _Default: + return self.timeout.clone() + + if isinstance(timeout, Timeout): + return timeout.clone() + else: + # User passed us an int/float. This is for backwards compatibility, + # can be removed later + return Timeout.from_float(timeout) + + def _raise_timeout(self, err, url, timeout_value): + """Is the error actually a timeout? Will raise a ReadTimeout or pass""" + + if isinstance(err, SocketTimeout): + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % timeout_value + ) + + # See the above comment about EAGAIN in Python 3. In Python 2 we have + # to specifically catch it and throw the timeout error + if hasattr(err, "errno") and err.errno in _blocking_errnos: + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % timeout_value + ) + + # Catch possible read timeouts thrown as SSL errors. If not the + # case, rethrow the original. We need to do this because of: + # http://bugs.python.org/issue10272 + if "timed out" in str(err) or "did not complete (read)" in str( + err + ): # Python < 2.7.4 + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % timeout_value + ) + + def _make_request( + self, conn, method, url, timeout=_Default, chunked=False, **httplib_request_kw + ): + """ + Perform a request on a given urllib connection object taken from our + pool. + + :param conn: + a connection from one of our connection pools + + :param timeout: + Socket timeout in seconds for the request. This can be a + float or integer, which will set the same timeout value for + the socket connect and the socket read, or an instance of + :class:`urllib3.util.Timeout`, which gives you more fine-grained + control over your timeouts. + """ + self.num_requests += 1 + + timeout_obj = self._get_timeout(timeout) + timeout_obj.start_connect() + conn.timeout = timeout_obj.connect_timeout + + # Trigger any extra validation we need to do. + try: + self._validate_conn(conn) + except (SocketTimeout, BaseSSLError) as e: + # Py2 raises this as a BaseSSLError, Py3 raises it as socket timeout. + self._raise_timeout(err=e, url=url, timeout_value=conn.timeout) + raise + + # conn.request() calls http.client.*.request, not the method in + # urllib3.request. It also calls makefile (recv) on the socket. + try: + if chunked: + conn.request_chunked(method, url, **httplib_request_kw) + else: + conn.request(method, url, **httplib_request_kw) + + # We are swallowing BrokenPipeError (errno.EPIPE) since the server is + # legitimately able to close the connection after sending a valid response. + # With this behaviour, the received response is still readable. + except BrokenPipeError: + # Python 3 + pass + except IOError as e: + # Python 2 and macOS/Linux + # EPIPE and ESHUTDOWN are BrokenPipeError on Python 2, and EPROTOTYPE is needed on macOS + # https://erickt.github.io/blog/2014/11/19/adventures-in-debugging-a-potential-osx-kernel-bug/ + if e.errno not in { + errno.EPIPE, + errno.ESHUTDOWN, + errno.EPROTOTYPE, + }: + raise + + # Reset the timeout for the recv() on the socket + read_timeout = timeout_obj.read_timeout + + # App Engine doesn't have a sock attr + if getattr(conn, "sock", None): + # In Python 3 socket.py will catch EAGAIN and return None when you + # try and read into the file pointer created by http.client, which + # instead raises a BadStatusLine exception. Instead of catching + # the exception and assuming all BadStatusLine exceptions are read + # timeouts, check for a zero timeout before making the request. + if read_timeout == 0: + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % read_timeout + ) + if read_timeout is Timeout.DEFAULT_TIMEOUT: + conn.sock.settimeout(socket.getdefaulttimeout()) + else: # None or a value + conn.sock.settimeout(read_timeout) + + # Receive the response from the server + try: + try: + # Python 2.7, use buffering of HTTP responses + httplib_response = conn.getresponse(buffering=True) + except TypeError: + # Python 3 + try: + httplib_response = conn.getresponse() + except BaseException as e: + # Remove the TypeError from the exception chain in + # Python 3 (including for exceptions like SystemExit). + # Otherwise it looks like a bug in the code. + six.raise_from(e, None) + except (SocketTimeout, BaseSSLError, SocketError) as e: + self._raise_timeout(err=e, url=url, timeout_value=read_timeout) + raise + + # AppEngine doesn't have a version attr. + http_version = getattr(conn, "_http_vsn_str", "HTTP/?") + log.debug( + '%s://%s:%s "%s %s %s" %s %s', + self.scheme, + self.host, + self.port, + method, + url, + http_version, + httplib_response.status, + httplib_response.length, + ) + + try: + assert_header_parsing(httplib_response.msg) + except (HeaderParsingError, TypeError) as hpe: # Platform-specific: Python 3 + log.warning( + "Failed to parse headers (url=%s): %s", + self._absolute_url(url), + hpe, + exc_info=True, + ) + + return httplib_response + + def _absolute_url(self, path): + return Url(scheme=self.scheme, host=self.host, port=self.port, path=path).url + + def close(self): + """ + Close all pooled connections and disable the pool. + """ + if self.pool is None: + return + # Disable access to the pool + old_pool, self.pool = self.pool, None + + try: + while True: + conn = old_pool.get(block=False) + if conn: + conn.close() + + except queue.Empty: + pass # Done. + + def is_same_host(self, url): + """ + Check if the given ``url`` is a member of the same host as this + connection pool. + """ + if url.startswith("/"): + return True + + # TODO: Add optional support for socket.gethostbyname checking. + scheme, host, port = get_host(url) + if host is not None: + host = _normalize_host(host, scheme=scheme) + + # Use explicit default port for comparison when none is given + if self.port and not port: + port = port_by_scheme.get(scheme) + elif not self.port and port == port_by_scheme.get(scheme): + port = None + + return (scheme, host, port) == (self.scheme, self.host, self.port) + + def urlopen( + self, + method, + url, + body=None, + headers=None, + retries=None, + redirect=True, + assert_same_host=True, + timeout=_Default, + pool_timeout=None, + release_conn=None, + chunked=False, + body_pos=None, + **response_kw + ): + """ + Get a connection from the pool and perform an HTTP request. This is the + lowest level call for making a request, so you'll need to specify all + the raw details. + + .. note:: + + More commonly, it's appropriate to use a convenience method provided + by :class:`.RequestMethods`, such as :meth:`request`. + + .. note:: + + `release_conn` will only behave as expected if + `preload_content=False` because we want to make + `preload_content=False` the default behaviour someday soon without + breaking backwards compatibility. + + :param method: + HTTP request method (such as GET, POST, PUT, etc.) + + :param url: + The URL to perform the request on. + + :param body: + Data to send in the request body, either :class:`str`, :class:`bytes`, + an iterable of :class:`str`/:class:`bytes`, or a file-like object. + + :param headers: + Dictionary of custom headers to send, such as User-Agent, + If-None-Match, etc. If None, pool headers are used. If provided, + these headers completely replace any pool-specific headers. + + :param retries: + Configure the number of retries to allow before raising a + :class:`~urllib3.exceptions.MaxRetryError` exception. + + Pass ``None`` to retry until you receive a response. Pass a + :class:`~urllib3.util.retry.Retry` object for fine-grained control + over different types of retries. + Pass an integer number to retry connection errors that many times, + but no other types of errors. Pass zero to never retry. + + If ``False``, then retries are disabled and any exception is raised + immediately. Also, instead of raising a MaxRetryError on redirects, + the redirect response will be returned. + + :type retries: :class:`~urllib3.util.retry.Retry`, False, or an int. + + :param redirect: + If True, automatically handle redirects (status codes 301, 302, + 303, 307, 308). Each redirect counts as a retry. Disabling retries + will disable redirect, too. + + :param assert_same_host: + If ``True``, will make sure that the host of the pool requests is + consistent else will raise HostChangedError. When ``False``, you can + use the pool on an HTTP proxy and request foreign hosts. + + :param timeout: + If specified, overrides the default timeout for this one + request. It may be a float (in seconds) or an instance of + :class:`urllib3.util.Timeout`. + + :param pool_timeout: + If set and the pool is set to block=True, then this method will + block for ``pool_timeout`` seconds and raise EmptyPoolError if no + connection is available within the time period. + + :param release_conn: + If False, then the urlopen call will not release the connection + back into the pool once a response is received (but will release if + you read the entire contents of the response such as when + `preload_content=True`). This is useful if you're not preloading + the response's content immediately. You will need to call + ``r.release_conn()`` on the response ``r`` to return the connection + back into the pool. If None, it takes the value of + ``response_kw.get('preload_content', True)``. + + :param chunked: + If True, urllib3 will send the body using chunked transfer + encoding. Otherwise, urllib3 will send the body using the standard + content-length form. Defaults to False. + + :param int body_pos: + Position to seek to in file-like body in the event of a retry or + redirect. Typically this won't need to be set because urllib3 will + auto-populate the value when needed. + + :param \\**response_kw: + Additional parameters are passed to + :meth:`urllib3.response.HTTPResponse.from_httplib` + """ + + parsed_url = parse_url(url) + destination_scheme = parsed_url.scheme + + if headers is None: + headers = self.headers + + if not isinstance(retries, Retry): + retries = Retry.from_int(retries, redirect=redirect, default=self.retries) + + if release_conn is None: + release_conn = response_kw.get("preload_content", True) + + # Check host + if assert_same_host and not self.is_same_host(url): + raise HostChangedError(self, url, retries) + + # Ensure that the URL we're connecting to is properly encoded + if url.startswith("/"): + url = six.ensure_str(_encode_target(url)) + else: + url = six.ensure_str(parsed_url.url) + + conn = None + + # Track whether `conn` needs to be released before + # returning/raising/recursing. Update this variable if necessary, and + # leave `release_conn` constant throughout the function. That way, if + # the function recurses, the original value of `release_conn` will be + # passed down into the recursive call, and its value will be respected. + # + # See issue #651 [1] for details. + # + # [1] + release_this_conn = release_conn + + http_tunnel_required = connection_requires_http_tunnel( + self.proxy, self.proxy_config, destination_scheme + ) + + # Merge the proxy headers. Only done when not using HTTP CONNECT. We + # have to copy the headers dict so we can safely change it without those + # changes being reflected in anyone else's copy. + if not http_tunnel_required: + headers = headers.copy() + headers.update(self.proxy_headers) + + # Must keep the exception bound to a separate variable or else Python 3 + # complains about UnboundLocalError. + err = None + + # Keep track of whether we cleanly exited the except block. This + # ensures we do proper cleanup in finally. + clean_exit = False + + # Rewind body position, if needed. Record current position + # for future rewinds in the event of a redirect/retry. + body_pos = set_file_position(body, body_pos) + + try: + # Request a connection from the queue. + timeout_obj = self._get_timeout(timeout) + conn = self._get_conn(timeout=pool_timeout) + + conn.timeout = timeout_obj.connect_timeout + + is_new_proxy_conn = self.proxy is not None and not getattr( + conn, "sock", None + ) + if is_new_proxy_conn and http_tunnel_required: + self._prepare_proxy(conn) + + # Make the request on the httplib connection object. + httplib_response = self._make_request( + conn, + method, + url, + timeout=timeout_obj, + body=body, + headers=headers, + chunked=chunked, + ) + + # If we're going to release the connection in ``finally:``, then + # the response doesn't need to know about the connection. Otherwise + # it will also try to release it and we'll have a double-release + # mess. + response_conn = conn if not release_conn else None + + # Pass method to Response for length checking + response_kw["request_method"] = method + + # Import httplib's response into our own wrapper object + response = self.ResponseCls.from_httplib( + httplib_response, + pool=self, + connection=response_conn, + retries=retries, + **response_kw + ) + + # Everything went great! + clean_exit = True + + except EmptyPoolError: + # Didn't get a connection from the pool, no need to clean up + clean_exit = True + release_this_conn = False + raise + + except ( + TimeoutError, + HTTPException, + SocketError, + ProtocolError, + BaseSSLError, + SSLError, + CertificateError, + ) as e: + # Discard the connection for these exceptions. It will be + # replaced during the next _get_conn() call. + clean_exit = False + if isinstance(e, (BaseSSLError, CertificateError)): + e = SSLError(e) + elif isinstance(e, (SocketError, NewConnectionError)) and self.proxy: + e = ProxyError("Cannot connect to proxy.", e) + elif isinstance(e, (SocketError, HTTPException)): + e = ProtocolError("Connection aborted.", e) + + retries = retries.increment( + method, url, error=e, _pool=self, _stacktrace=sys.exc_info()[2] + ) + retries.sleep() + + # Keep track of the error for the retry warning. + err = e + + finally: + if not clean_exit: + # We hit some kind of exception, handled or otherwise. We need + # to throw the connection away unless explicitly told not to. + # Close the connection, set the variable to None, and make sure + # we put the None back in the pool to avoid leaking it. + conn = conn and conn.close() + release_this_conn = True + + if release_this_conn: + # Put the connection back to be reused. If the connection is + # expired then it will be None, which will get replaced with a + # fresh connection during _get_conn. + self._put_conn(conn) + + if not conn: + # Try again + log.warning( + "Retrying (%r) after connection broken by '%r': %s", retries, err, url + ) + return self.urlopen( + method, + url, + body, + headers, + retries, + redirect, + assert_same_host, + timeout=timeout, + pool_timeout=pool_timeout, + release_conn=release_conn, + chunked=chunked, + body_pos=body_pos, + **response_kw + ) + + # Handle redirect? + redirect_location = redirect and response.get_redirect_location() + if redirect_location: + if response.status == 303: + method = "GET" + + try: + retries = retries.increment(method, url, response=response, _pool=self) + except MaxRetryError: + if retries.raise_on_redirect: + response.drain_conn() + raise + return response + + response.drain_conn() + retries.sleep_for_retry(response) + log.debug("Redirecting %s -> %s", url, redirect_location) + return self.urlopen( + method, + redirect_location, + body, + headers, + retries=retries, + redirect=redirect, + assert_same_host=assert_same_host, + timeout=timeout, + pool_timeout=pool_timeout, + release_conn=release_conn, + chunked=chunked, + body_pos=body_pos, + **response_kw + ) + + # Check if we should retry the HTTP response. + has_retry_after = bool(response.getheader("Retry-After")) + if retries.is_retry(method, response.status, has_retry_after): + try: + retries = retries.increment(method, url, response=response, _pool=self) + except MaxRetryError: + if retries.raise_on_status: + response.drain_conn() + raise + return response + + response.drain_conn() + retries.sleep(response) + log.debug("Retry: %s", url) + return self.urlopen( + method, + url, + body, + headers, + retries=retries, + redirect=redirect, + assert_same_host=assert_same_host, + timeout=timeout, + pool_timeout=pool_timeout, + release_conn=release_conn, + chunked=chunked, + body_pos=body_pos, + **response_kw + ) + + return response + + +class HTTPSConnectionPool(HTTPConnectionPool): + """ + Same as :class:`.HTTPConnectionPool`, but HTTPS. + + :class:`.HTTPSConnection` uses one of ``assert_fingerprint``, + ``assert_hostname`` and ``host`` in this order to verify connections. + If ``assert_hostname`` is False, no verification is done. + + The ``key_file``, ``cert_file``, ``cert_reqs``, ``ca_certs``, + ``ca_cert_dir``, ``ssl_version``, ``key_password`` are only used if :mod:`ssl` + is available and are fed into :meth:`urllib3.util.ssl_wrap_socket` to upgrade + the connection socket into an SSL socket. + """ + + scheme = "https" + ConnectionCls = HTTPSConnection + + def __init__( + self, + host, + port=None, + strict=False, + timeout=Timeout.DEFAULT_TIMEOUT, + maxsize=1, + block=False, + headers=None, + retries=None, + _proxy=None, + _proxy_headers=None, + key_file=None, + cert_file=None, + cert_reqs=None, + key_password=None, + ca_certs=None, + ssl_version=None, + assert_hostname=None, + assert_fingerprint=None, + ca_cert_dir=None, + **conn_kw + ): + + HTTPConnectionPool.__init__( + self, + host, + port, + strict, + timeout, + maxsize, + block, + headers, + retries, + _proxy, + _proxy_headers, + **conn_kw + ) + + self.key_file = key_file + self.cert_file = cert_file + self.cert_reqs = cert_reqs + self.key_password = key_password + self.ca_certs = ca_certs + self.ca_cert_dir = ca_cert_dir + self.ssl_version = ssl_version + self.assert_hostname = assert_hostname + self.assert_fingerprint = assert_fingerprint + + def _prepare_conn(self, conn): + """ + Prepare the ``connection`` for :meth:`urllib3.util.ssl_wrap_socket` + and establish the tunnel if proxy is used. + """ + + if isinstance(conn, VerifiedHTTPSConnection): + conn.set_cert( + key_file=self.key_file, + key_password=self.key_password, + cert_file=self.cert_file, + cert_reqs=self.cert_reqs, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + assert_hostname=self.assert_hostname, + assert_fingerprint=self.assert_fingerprint, + ) + conn.ssl_version = self.ssl_version + return conn + + def _prepare_proxy(self, conn): + """ + Establishes a tunnel connection through HTTP CONNECT. + + Tunnel connection is established early because otherwise httplib would + improperly set Host: header to proxy's IP:port. + """ + + conn.set_tunnel(self._proxy_host, self.port, self.proxy_headers) + + if self.proxy.scheme == "https": + conn.tls_in_tls_required = True + + conn.connect() + + def _new_conn(self): + """ + Return a fresh :class:`http.client.HTTPSConnection`. + """ + self.num_connections += 1 + log.debug( + "Starting new HTTPS connection (%d): %s:%s", + self.num_connections, + self.host, + self.port or "443", + ) + + if not self.ConnectionCls or self.ConnectionCls is DummyConnection: + raise SSLError( + "Can't connect to HTTPS URL because the SSL module is not available." + ) + + actual_host = self.host + actual_port = self.port + if self.proxy is not None: + actual_host = self.proxy.host + actual_port = self.proxy.port + + conn = self.ConnectionCls( + host=actual_host, + port=actual_port, + timeout=self.timeout.connect_timeout, + strict=self.strict, + cert_file=self.cert_file, + key_file=self.key_file, + key_password=self.key_password, + **self.conn_kw + ) + + return self._prepare_conn(conn) + + def _validate_conn(self, conn): + """ + Called right before a request is made, after the socket is created. + """ + super(HTTPSConnectionPool, self)._validate_conn(conn) + + # Force connect early to allow us to validate the connection. + if not getattr(conn, "sock", None): # AppEngine might not have `.sock` + conn.connect() + + if not conn.is_verified: + warnings.warn( + ( + "Unverified HTTPS request is being made to host '%s'. " + "Adding certificate verification is strongly advised. See: " + "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" + "#ssl-warnings" % conn.host + ), + InsecureRequestWarning, + ) + + +def connection_from_url(url, **kw): + """ + Given a url, return an :class:`.ConnectionPool` instance of its host. + + This is a shortcut for not having to parse out the scheme, host, and port + of the url before creating an :class:`.ConnectionPool` instance. + + :param url: + Absolute URL string that must include the scheme. Port is optional. + + :param \\**kw: + Passes additional parameters to the constructor of the appropriate + :class:`.ConnectionPool`. Useful for specifying things like + timeout, maxsize, headers, etc. + + Example:: + + >>> conn = connection_from_url('http://google.com/') + >>> r = conn.request('GET', '/') + """ + scheme, host, port = get_host(url) + port = port or port_by_scheme.get(scheme, 80) + if scheme == "https": + return HTTPSConnectionPool(host, port=port, **kw) + else: + return HTTPConnectionPool(host, port=port, **kw) + + +def _normalize_host(host, scheme): + """ + Normalize hosts for comparisons and use with sockets. + """ + + host = normalize_host(host, scheme) + + # httplib doesn't like it when we include brackets in IPv6 addresses + # Specifically, if we include brackets but also pass the port then + # httplib crazily doubles up the square brackets on the Host header. + # Instead, we need to make sure we never pass ``None`` as the port. + # However, for backward compatibility reasons we can't actually + # *assert* that. See http://bugs.python.org/issue28539 + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + return host diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/__init__.py b/openpype/hosts/fusion/vendor/urllib3/contrib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py new file mode 100644 index 0000000000..8765b907d7 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py @@ -0,0 +1,36 @@ +""" +This module provides means to detect the App Engine environment. +""" + +import os + + +def is_appengine(): + return is_local_appengine() or is_prod_appengine() + + +def is_appengine_sandbox(): + """Reports if the app is running in the first generation sandbox. + + The second generation runtimes are technically still in a sandbox, but it + is much less restrictive, so generally you shouldn't need to check for it. + see https://cloud.google.com/appengine/docs/standard/runtimes + """ + return is_appengine() and os.environ["APPENGINE_RUNTIME"] == "python27" + + +def is_local_appengine(): + return "APPENGINE_RUNTIME" in os.environ and os.environ.get( + "SERVER_SOFTWARE", "" + ).startswith("Development/") + + +def is_prod_appengine(): + return "APPENGINE_RUNTIME" in os.environ and os.environ.get( + "SERVER_SOFTWARE", "" + ).startswith("Google App Engine/") + + +def is_prod_appengine_mvms(): + """Deprecated.""" + return False diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/__init__.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py new file mode 100644 index 0000000000..11524d400b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py @@ -0,0 +1,519 @@ +""" +This module uses ctypes to bind a whole bunch of functions and constants from +SecureTransport. The goal here is to provide the low-level API to +SecureTransport. These are essentially the C-level functions and constants, and +they're pretty gross to work with. + +This code is a bastardised version of the code found in Will Bond's oscrypto +library. An enormous debt is owed to him for blazing this trail for us. For +that reason, this code should be considered to be covered both by urllib3's +license and by oscrypto's: + + Copyright (c) 2015-2016 Will Bond + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +""" +from __future__ import absolute_import + +import platform +from ctypes import ( + CDLL, + CFUNCTYPE, + POINTER, + c_bool, + c_byte, + c_char_p, + c_int32, + c_long, + c_size_t, + c_uint32, + c_ulong, + c_void_p, +) +from ctypes.util import find_library + +from urllib3.packages.six import raise_from + +if platform.system() != "Darwin": + raise ImportError("Only macOS is supported") + +version = platform.mac_ver()[0] +version_info = tuple(map(int, version.split("."))) +if version_info < (10, 8): + raise OSError( + "Only OS X 10.8 and newer are supported, not %s.%s" + % (version_info[0], version_info[1]) + ) + + +def load_cdll(name, macos10_16_path): + """Loads a CDLL by name, falling back to known path on 10.16+""" + try: + # Big Sur is technically 11 but we use 10.16 due to the Big Sur + # beta being labeled as 10.16. + if version_info >= (10, 16): + path = macos10_16_path + else: + path = find_library(name) + if not path: + raise OSError # Caught and reraised as 'ImportError' + return CDLL(path, use_errno=True) + except OSError: + raise_from(ImportError("The library %s failed to load" % name), None) + + +Security = load_cdll( + "Security", "/System/Library/Frameworks/Security.framework/Security" +) +CoreFoundation = load_cdll( + "CoreFoundation", + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", +) + + +Boolean = c_bool +CFIndex = c_long +CFStringEncoding = c_uint32 +CFData = c_void_p +CFString = c_void_p +CFArray = c_void_p +CFMutableArray = c_void_p +CFDictionary = c_void_p +CFError = c_void_p +CFType = c_void_p +CFTypeID = c_ulong + +CFTypeRef = POINTER(CFType) +CFAllocatorRef = c_void_p + +OSStatus = c_int32 + +CFDataRef = POINTER(CFData) +CFStringRef = POINTER(CFString) +CFArrayRef = POINTER(CFArray) +CFMutableArrayRef = POINTER(CFMutableArray) +CFDictionaryRef = POINTER(CFDictionary) +CFArrayCallBacks = c_void_p +CFDictionaryKeyCallBacks = c_void_p +CFDictionaryValueCallBacks = c_void_p + +SecCertificateRef = POINTER(c_void_p) +SecExternalFormat = c_uint32 +SecExternalItemType = c_uint32 +SecIdentityRef = POINTER(c_void_p) +SecItemImportExportFlags = c_uint32 +SecItemImportExportKeyParameters = c_void_p +SecKeychainRef = POINTER(c_void_p) +SSLProtocol = c_uint32 +SSLCipherSuite = c_uint32 +SSLContextRef = POINTER(c_void_p) +SecTrustRef = POINTER(c_void_p) +SSLConnectionRef = c_uint32 +SecTrustResultType = c_uint32 +SecTrustOptionFlags = c_uint32 +SSLProtocolSide = c_uint32 +SSLConnectionType = c_uint32 +SSLSessionOption = c_uint32 + + +try: + Security.SecItemImport.argtypes = [ + CFDataRef, + CFStringRef, + POINTER(SecExternalFormat), + POINTER(SecExternalItemType), + SecItemImportExportFlags, + POINTER(SecItemImportExportKeyParameters), + SecKeychainRef, + POINTER(CFArrayRef), + ] + Security.SecItemImport.restype = OSStatus + + Security.SecCertificateGetTypeID.argtypes = [] + Security.SecCertificateGetTypeID.restype = CFTypeID + + Security.SecIdentityGetTypeID.argtypes = [] + Security.SecIdentityGetTypeID.restype = CFTypeID + + Security.SecKeyGetTypeID.argtypes = [] + Security.SecKeyGetTypeID.restype = CFTypeID + + Security.SecCertificateCreateWithData.argtypes = [CFAllocatorRef, CFDataRef] + Security.SecCertificateCreateWithData.restype = SecCertificateRef + + Security.SecCertificateCopyData.argtypes = [SecCertificateRef] + Security.SecCertificateCopyData.restype = CFDataRef + + Security.SecCopyErrorMessageString.argtypes = [OSStatus, c_void_p] + Security.SecCopyErrorMessageString.restype = CFStringRef + + Security.SecIdentityCreateWithCertificate.argtypes = [ + CFTypeRef, + SecCertificateRef, + POINTER(SecIdentityRef), + ] + Security.SecIdentityCreateWithCertificate.restype = OSStatus + + Security.SecKeychainCreate.argtypes = [ + c_char_p, + c_uint32, + c_void_p, + Boolean, + c_void_p, + POINTER(SecKeychainRef), + ] + Security.SecKeychainCreate.restype = OSStatus + + Security.SecKeychainDelete.argtypes = [SecKeychainRef] + Security.SecKeychainDelete.restype = OSStatus + + Security.SecPKCS12Import.argtypes = [ + CFDataRef, + CFDictionaryRef, + POINTER(CFArrayRef), + ] + Security.SecPKCS12Import.restype = OSStatus + + SSLReadFunc = CFUNCTYPE(OSStatus, SSLConnectionRef, c_void_p, POINTER(c_size_t)) + SSLWriteFunc = CFUNCTYPE( + OSStatus, SSLConnectionRef, POINTER(c_byte), POINTER(c_size_t) + ) + + Security.SSLSetIOFuncs.argtypes = [SSLContextRef, SSLReadFunc, SSLWriteFunc] + Security.SSLSetIOFuncs.restype = OSStatus + + Security.SSLSetPeerID.argtypes = [SSLContextRef, c_char_p, c_size_t] + Security.SSLSetPeerID.restype = OSStatus + + Security.SSLSetCertificate.argtypes = [SSLContextRef, CFArrayRef] + Security.SSLSetCertificate.restype = OSStatus + + Security.SSLSetCertificateAuthorities.argtypes = [SSLContextRef, CFTypeRef, Boolean] + Security.SSLSetCertificateAuthorities.restype = OSStatus + + Security.SSLSetConnection.argtypes = [SSLContextRef, SSLConnectionRef] + Security.SSLSetConnection.restype = OSStatus + + Security.SSLSetPeerDomainName.argtypes = [SSLContextRef, c_char_p, c_size_t] + Security.SSLSetPeerDomainName.restype = OSStatus + + Security.SSLHandshake.argtypes = [SSLContextRef] + Security.SSLHandshake.restype = OSStatus + + Security.SSLRead.argtypes = [SSLContextRef, c_char_p, c_size_t, POINTER(c_size_t)] + Security.SSLRead.restype = OSStatus + + Security.SSLWrite.argtypes = [SSLContextRef, c_char_p, c_size_t, POINTER(c_size_t)] + Security.SSLWrite.restype = OSStatus + + Security.SSLClose.argtypes = [SSLContextRef] + Security.SSLClose.restype = OSStatus + + Security.SSLGetNumberSupportedCiphers.argtypes = [SSLContextRef, POINTER(c_size_t)] + Security.SSLGetNumberSupportedCiphers.restype = OSStatus + + Security.SSLGetSupportedCiphers.argtypes = [ + SSLContextRef, + POINTER(SSLCipherSuite), + POINTER(c_size_t), + ] + Security.SSLGetSupportedCiphers.restype = OSStatus + + Security.SSLSetEnabledCiphers.argtypes = [ + SSLContextRef, + POINTER(SSLCipherSuite), + c_size_t, + ] + Security.SSLSetEnabledCiphers.restype = OSStatus + + Security.SSLGetNumberEnabledCiphers.argtype = [SSLContextRef, POINTER(c_size_t)] + Security.SSLGetNumberEnabledCiphers.restype = OSStatus + + Security.SSLGetEnabledCiphers.argtypes = [ + SSLContextRef, + POINTER(SSLCipherSuite), + POINTER(c_size_t), + ] + Security.SSLGetEnabledCiphers.restype = OSStatus + + Security.SSLGetNegotiatedCipher.argtypes = [SSLContextRef, POINTER(SSLCipherSuite)] + Security.SSLGetNegotiatedCipher.restype = OSStatus + + Security.SSLGetNegotiatedProtocolVersion.argtypes = [ + SSLContextRef, + POINTER(SSLProtocol), + ] + Security.SSLGetNegotiatedProtocolVersion.restype = OSStatus + + Security.SSLCopyPeerTrust.argtypes = [SSLContextRef, POINTER(SecTrustRef)] + Security.SSLCopyPeerTrust.restype = OSStatus + + Security.SecTrustSetAnchorCertificates.argtypes = [SecTrustRef, CFArrayRef] + Security.SecTrustSetAnchorCertificates.restype = OSStatus + + Security.SecTrustSetAnchorCertificatesOnly.argstypes = [SecTrustRef, Boolean] + Security.SecTrustSetAnchorCertificatesOnly.restype = OSStatus + + Security.SecTrustEvaluate.argtypes = [SecTrustRef, POINTER(SecTrustResultType)] + Security.SecTrustEvaluate.restype = OSStatus + + Security.SecTrustGetCertificateCount.argtypes = [SecTrustRef] + Security.SecTrustGetCertificateCount.restype = CFIndex + + Security.SecTrustGetCertificateAtIndex.argtypes = [SecTrustRef, CFIndex] + Security.SecTrustGetCertificateAtIndex.restype = SecCertificateRef + + Security.SSLCreateContext.argtypes = [ + CFAllocatorRef, + SSLProtocolSide, + SSLConnectionType, + ] + Security.SSLCreateContext.restype = SSLContextRef + + Security.SSLSetSessionOption.argtypes = [SSLContextRef, SSLSessionOption, Boolean] + Security.SSLSetSessionOption.restype = OSStatus + + Security.SSLSetProtocolVersionMin.argtypes = [SSLContextRef, SSLProtocol] + Security.SSLSetProtocolVersionMin.restype = OSStatus + + Security.SSLSetProtocolVersionMax.argtypes = [SSLContextRef, SSLProtocol] + Security.SSLSetProtocolVersionMax.restype = OSStatus + + try: + Security.SSLSetALPNProtocols.argtypes = [SSLContextRef, CFArrayRef] + Security.SSLSetALPNProtocols.restype = OSStatus + except AttributeError: + # Supported only in 10.12+ + pass + + Security.SecCopyErrorMessageString.argtypes = [OSStatus, c_void_p] + Security.SecCopyErrorMessageString.restype = CFStringRef + + Security.SSLReadFunc = SSLReadFunc + Security.SSLWriteFunc = SSLWriteFunc + Security.SSLContextRef = SSLContextRef + Security.SSLProtocol = SSLProtocol + Security.SSLCipherSuite = SSLCipherSuite + Security.SecIdentityRef = SecIdentityRef + Security.SecKeychainRef = SecKeychainRef + Security.SecTrustRef = SecTrustRef + Security.SecTrustResultType = SecTrustResultType + Security.SecExternalFormat = SecExternalFormat + Security.OSStatus = OSStatus + + Security.kSecImportExportPassphrase = CFStringRef.in_dll( + Security, "kSecImportExportPassphrase" + ) + Security.kSecImportItemIdentity = CFStringRef.in_dll( + Security, "kSecImportItemIdentity" + ) + + # CoreFoundation time! + CoreFoundation.CFRetain.argtypes = [CFTypeRef] + CoreFoundation.CFRetain.restype = CFTypeRef + + CoreFoundation.CFRelease.argtypes = [CFTypeRef] + CoreFoundation.CFRelease.restype = None + + CoreFoundation.CFGetTypeID.argtypes = [CFTypeRef] + CoreFoundation.CFGetTypeID.restype = CFTypeID + + CoreFoundation.CFStringCreateWithCString.argtypes = [ + CFAllocatorRef, + c_char_p, + CFStringEncoding, + ] + CoreFoundation.CFStringCreateWithCString.restype = CFStringRef + + CoreFoundation.CFStringGetCStringPtr.argtypes = [CFStringRef, CFStringEncoding] + CoreFoundation.CFStringGetCStringPtr.restype = c_char_p + + CoreFoundation.CFStringGetCString.argtypes = [ + CFStringRef, + c_char_p, + CFIndex, + CFStringEncoding, + ] + CoreFoundation.CFStringGetCString.restype = c_bool + + CoreFoundation.CFDataCreate.argtypes = [CFAllocatorRef, c_char_p, CFIndex] + CoreFoundation.CFDataCreate.restype = CFDataRef + + CoreFoundation.CFDataGetLength.argtypes = [CFDataRef] + CoreFoundation.CFDataGetLength.restype = CFIndex + + CoreFoundation.CFDataGetBytePtr.argtypes = [CFDataRef] + CoreFoundation.CFDataGetBytePtr.restype = c_void_p + + CoreFoundation.CFDictionaryCreate.argtypes = [ + CFAllocatorRef, + POINTER(CFTypeRef), + POINTER(CFTypeRef), + CFIndex, + CFDictionaryKeyCallBacks, + CFDictionaryValueCallBacks, + ] + CoreFoundation.CFDictionaryCreate.restype = CFDictionaryRef + + CoreFoundation.CFDictionaryGetValue.argtypes = [CFDictionaryRef, CFTypeRef] + CoreFoundation.CFDictionaryGetValue.restype = CFTypeRef + + CoreFoundation.CFArrayCreate.argtypes = [ + CFAllocatorRef, + POINTER(CFTypeRef), + CFIndex, + CFArrayCallBacks, + ] + CoreFoundation.CFArrayCreate.restype = CFArrayRef + + CoreFoundation.CFArrayCreateMutable.argtypes = [ + CFAllocatorRef, + CFIndex, + CFArrayCallBacks, + ] + CoreFoundation.CFArrayCreateMutable.restype = CFMutableArrayRef + + CoreFoundation.CFArrayAppendValue.argtypes = [CFMutableArrayRef, c_void_p] + CoreFoundation.CFArrayAppendValue.restype = None + + CoreFoundation.CFArrayGetCount.argtypes = [CFArrayRef] + CoreFoundation.CFArrayGetCount.restype = CFIndex + + CoreFoundation.CFArrayGetValueAtIndex.argtypes = [CFArrayRef, CFIndex] + CoreFoundation.CFArrayGetValueAtIndex.restype = c_void_p + + CoreFoundation.kCFAllocatorDefault = CFAllocatorRef.in_dll( + CoreFoundation, "kCFAllocatorDefault" + ) + CoreFoundation.kCFTypeArrayCallBacks = c_void_p.in_dll( + CoreFoundation, "kCFTypeArrayCallBacks" + ) + CoreFoundation.kCFTypeDictionaryKeyCallBacks = c_void_p.in_dll( + CoreFoundation, "kCFTypeDictionaryKeyCallBacks" + ) + CoreFoundation.kCFTypeDictionaryValueCallBacks = c_void_p.in_dll( + CoreFoundation, "kCFTypeDictionaryValueCallBacks" + ) + + CoreFoundation.CFTypeRef = CFTypeRef + CoreFoundation.CFArrayRef = CFArrayRef + CoreFoundation.CFStringRef = CFStringRef + CoreFoundation.CFDictionaryRef = CFDictionaryRef + +except (AttributeError): + raise ImportError("Error initializing ctypes") + + +class CFConst(object): + """ + A class object that acts as essentially a namespace for CoreFoundation + constants. + """ + + kCFStringEncodingUTF8 = CFStringEncoding(0x08000100) + + +class SecurityConst(object): + """ + A class object that acts as essentially a namespace for Security constants. + """ + + kSSLSessionOptionBreakOnServerAuth = 0 + + kSSLProtocol2 = 1 + kSSLProtocol3 = 2 + kTLSProtocol1 = 4 + kTLSProtocol11 = 7 + kTLSProtocol12 = 8 + # SecureTransport does not support TLS 1.3 even if there's a constant for it + kTLSProtocol13 = 10 + kTLSProtocolMaxSupported = 999 + + kSSLClientSide = 1 + kSSLStreamType = 0 + + kSecFormatPEMSequence = 10 + + kSecTrustResultInvalid = 0 + kSecTrustResultProceed = 1 + # This gap is present on purpose: this was kSecTrustResultConfirm, which + # is deprecated. + kSecTrustResultDeny = 3 + kSecTrustResultUnspecified = 4 + kSecTrustResultRecoverableTrustFailure = 5 + kSecTrustResultFatalTrustFailure = 6 + kSecTrustResultOtherError = 7 + + errSSLProtocol = -9800 + errSSLWouldBlock = -9803 + errSSLClosedGraceful = -9805 + errSSLClosedNoNotify = -9816 + errSSLClosedAbort = -9806 + + errSSLXCertChainInvalid = -9807 + errSSLCrypto = -9809 + errSSLInternal = -9810 + errSSLCertExpired = -9814 + errSSLCertNotYetValid = -9815 + errSSLUnknownRootCert = -9812 + errSSLNoRootCert = -9813 + errSSLHostNameMismatch = -9843 + errSSLPeerHandshakeFail = -9824 + errSSLPeerUserCancelled = -9839 + errSSLWeakPeerEphemeralDHKey = -9850 + errSSLServerAuthCompleted = -9841 + errSSLRecordOverflow = -9847 + + errSecVerifyFailed = -67808 + errSecNoTrustSettings = -25263 + errSecItemNotFound = -25300 + errSecInvalidTrustSettings = -25262 + + # Cipher suites. We only pick the ones our default cipher string allows. + # Source: https://developer.apple.com/documentation/security/1550981-ssl_cipher_suite_values + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 = 0xC02C + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 = 0xC030 + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 = 0xC02B + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 0xC02F + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA9 + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA8 + TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 = 0x009F + TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 = 0x009E + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 = 0xC024 + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 = 0xC028 + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA = 0xC00A + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA = 0xC014 + TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 = 0x006B + TLS_DHE_RSA_WITH_AES_256_CBC_SHA = 0x0039 + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 = 0xC023 + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 = 0xC027 + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA = 0xC009 + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA = 0xC013 + TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 = 0x0067 + TLS_DHE_RSA_WITH_AES_128_CBC_SHA = 0x0033 + TLS_RSA_WITH_AES_256_GCM_SHA384 = 0x009D + TLS_RSA_WITH_AES_128_GCM_SHA256 = 0x009C + TLS_RSA_WITH_AES_256_CBC_SHA256 = 0x003D + TLS_RSA_WITH_AES_128_CBC_SHA256 = 0x003C + TLS_RSA_WITH_AES_256_CBC_SHA = 0x0035 + TLS_RSA_WITH_AES_128_CBC_SHA = 0x002F + TLS_AES_128_GCM_SHA256 = 0x1301 + TLS_AES_256_GCM_SHA384 = 0x1302 + TLS_AES_128_CCM_8_SHA256 = 0x1305 + TLS_AES_128_CCM_SHA256 = 0x1304 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py new file mode 100644 index 0000000000..ed8120190c --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py @@ -0,0 +1,396 @@ +""" +Low-level helpers for the SecureTransport bindings. + +These are Python functions that are not directly related to the high-level APIs +but are necessary to get them to work. They include a whole bunch of low-level +CoreFoundation messing about and memory management. The concerns in this module +are almost entirely about trying to avoid memory leaks and providing +appropriate and useful assistance to the higher-level code. +""" +import base64 +import ctypes +import itertools +import os +import re +import ssl +import struct +import tempfile + +from .bindings import CFConst, CoreFoundation, Security + +# This regular expression is used to grab PEM data out of a PEM bundle. +_PEM_CERTS_RE = re.compile( + b"-----BEGIN CERTIFICATE-----\n(.*?)\n-----END CERTIFICATE-----", re.DOTALL +) + + +def _cf_data_from_bytes(bytestring): + """ + Given a bytestring, create a CFData object from it. This CFData object must + be CFReleased by the caller. + """ + return CoreFoundation.CFDataCreate( + CoreFoundation.kCFAllocatorDefault, bytestring, len(bytestring) + ) + + +def _cf_dictionary_from_tuples(tuples): + """ + Given a list of Python tuples, create an associated CFDictionary. + """ + dictionary_size = len(tuples) + + # We need to get the dictionary keys and values out in the same order. + keys = (t[0] for t in tuples) + values = (t[1] for t in tuples) + cf_keys = (CoreFoundation.CFTypeRef * dictionary_size)(*keys) + cf_values = (CoreFoundation.CFTypeRef * dictionary_size)(*values) + + return CoreFoundation.CFDictionaryCreate( + CoreFoundation.kCFAllocatorDefault, + cf_keys, + cf_values, + dictionary_size, + CoreFoundation.kCFTypeDictionaryKeyCallBacks, + CoreFoundation.kCFTypeDictionaryValueCallBacks, + ) + + +def _cfstr(py_bstr): + """ + Given a Python binary data, create a CFString. + The string must be CFReleased by the caller. + """ + c_str = ctypes.c_char_p(py_bstr) + cf_str = CoreFoundation.CFStringCreateWithCString( + CoreFoundation.kCFAllocatorDefault, + c_str, + CFConst.kCFStringEncodingUTF8, + ) + return cf_str + + +def _create_cfstring_array(lst): + """ + Given a list of Python binary data, create an associated CFMutableArray. + The array must be CFReleased by the caller. + + Raises an ssl.SSLError on failure. + """ + cf_arr = None + try: + cf_arr = CoreFoundation.CFArrayCreateMutable( + CoreFoundation.kCFAllocatorDefault, + 0, + ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), + ) + if not cf_arr: + raise MemoryError("Unable to allocate memory!") + for item in lst: + cf_str = _cfstr(item) + if not cf_str: + raise MemoryError("Unable to allocate memory!") + try: + CoreFoundation.CFArrayAppendValue(cf_arr, cf_str) + finally: + CoreFoundation.CFRelease(cf_str) + except BaseException as e: + if cf_arr: + CoreFoundation.CFRelease(cf_arr) + raise ssl.SSLError("Unable to allocate array: %s" % (e,)) + return cf_arr + + +def _cf_string_to_unicode(value): + """ + Creates a Unicode string from a CFString object. Used entirely for error + reporting. + + Yes, it annoys me quite a lot that this function is this complex. + """ + value_as_void_p = ctypes.cast(value, ctypes.POINTER(ctypes.c_void_p)) + + string = CoreFoundation.CFStringGetCStringPtr( + value_as_void_p, CFConst.kCFStringEncodingUTF8 + ) + if string is None: + buffer = ctypes.create_string_buffer(1024) + result = CoreFoundation.CFStringGetCString( + value_as_void_p, buffer, 1024, CFConst.kCFStringEncodingUTF8 + ) + if not result: + raise OSError("Error copying C string from CFStringRef") + string = buffer.value + if string is not None: + string = string.decode("utf-8") + return string + + +def _assert_no_error(error, exception_class=None): + """ + Checks the return code and throws an exception if there is an error to + report + """ + if error == 0: + return + + cf_error_string = Security.SecCopyErrorMessageString(error, None) + output = _cf_string_to_unicode(cf_error_string) + CoreFoundation.CFRelease(cf_error_string) + + if output is None or output == u"": + output = u"OSStatus %s" % error + + if exception_class is None: + exception_class = ssl.SSLError + + raise exception_class(output) + + +def _cert_array_from_pem(pem_bundle): + """ + Given a bundle of certs in PEM format, turns them into a CFArray of certs + that can be used to validate a cert chain. + """ + # Normalize the PEM bundle's line endings. + pem_bundle = pem_bundle.replace(b"\r\n", b"\n") + + der_certs = [ + base64.b64decode(match.group(1)) for match in _PEM_CERTS_RE.finditer(pem_bundle) + ] + if not der_certs: + raise ssl.SSLError("No root certificates specified") + + cert_array = CoreFoundation.CFArrayCreateMutable( + CoreFoundation.kCFAllocatorDefault, + 0, + ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), + ) + if not cert_array: + raise ssl.SSLError("Unable to allocate memory!") + + try: + for der_bytes in der_certs: + certdata = _cf_data_from_bytes(der_bytes) + if not certdata: + raise ssl.SSLError("Unable to allocate memory!") + cert = Security.SecCertificateCreateWithData( + CoreFoundation.kCFAllocatorDefault, certdata + ) + CoreFoundation.CFRelease(certdata) + if not cert: + raise ssl.SSLError("Unable to build cert object!") + + CoreFoundation.CFArrayAppendValue(cert_array, cert) + CoreFoundation.CFRelease(cert) + except Exception: + # We need to free the array before the exception bubbles further. + # We only want to do that if an error occurs: otherwise, the caller + # should free. + CoreFoundation.CFRelease(cert_array) + + return cert_array + + +def _is_cert(item): + """ + Returns True if a given CFTypeRef is a certificate. + """ + expected = Security.SecCertificateGetTypeID() + return CoreFoundation.CFGetTypeID(item) == expected + + +def _is_identity(item): + """ + Returns True if a given CFTypeRef is an identity. + """ + expected = Security.SecIdentityGetTypeID() + return CoreFoundation.CFGetTypeID(item) == expected + + +def _temporary_keychain(): + """ + This function creates a temporary Mac keychain that we can use to work with + credentials. This keychain uses a one-time password and a temporary file to + store the data. We expect to have one keychain per socket. The returned + SecKeychainRef must be freed by the caller, including calling + SecKeychainDelete. + + Returns a tuple of the SecKeychainRef and the path to the temporary + directory that contains it. + """ + # Unfortunately, SecKeychainCreate requires a path to a keychain. This + # means we cannot use mkstemp to use a generic temporary file. Instead, + # we're going to create a temporary directory and a filename to use there. + # This filename will be 8 random bytes expanded into base64. We also need + # some random bytes to password-protect the keychain we're creating, so we + # ask for 40 random bytes. + random_bytes = os.urandom(40) + filename = base64.b16encode(random_bytes[:8]).decode("utf-8") + password = base64.b16encode(random_bytes[8:]) # Must be valid UTF-8 + tempdirectory = tempfile.mkdtemp() + + keychain_path = os.path.join(tempdirectory, filename).encode("utf-8") + + # We now want to create the keychain itself. + keychain = Security.SecKeychainRef() + status = Security.SecKeychainCreate( + keychain_path, len(password), password, False, None, ctypes.byref(keychain) + ) + _assert_no_error(status) + + # Having created the keychain, we want to pass it off to the caller. + return keychain, tempdirectory + + +def _load_items_from_file(keychain, path): + """ + Given a single file, loads all the trust objects from it into arrays and + the keychain. + Returns a tuple of lists: the first list is a list of identities, the + second a list of certs. + """ + certificates = [] + identities = [] + result_array = None + + with open(path, "rb") as f: + raw_filedata = f.read() + + try: + filedata = CoreFoundation.CFDataCreate( + CoreFoundation.kCFAllocatorDefault, raw_filedata, len(raw_filedata) + ) + result_array = CoreFoundation.CFArrayRef() + result = Security.SecItemImport( + filedata, # cert data + None, # Filename, leaving it out for now + None, # What the type of the file is, we don't care + None, # what's in the file, we don't care + 0, # import flags + None, # key params, can include passphrase in the future + keychain, # The keychain to insert into + ctypes.byref(result_array), # Results + ) + _assert_no_error(result) + + # A CFArray is not very useful to us as an intermediary + # representation, so we are going to extract the objects we want + # and then free the array. We don't need to keep hold of keys: the + # keychain already has them! + result_count = CoreFoundation.CFArrayGetCount(result_array) + for index in range(result_count): + item = CoreFoundation.CFArrayGetValueAtIndex(result_array, index) + item = ctypes.cast(item, CoreFoundation.CFTypeRef) + + if _is_cert(item): + CoreFoundation.CFRetain(item) + certificates.append(item) + elif _is_identity(item): + CoreFoundation.CFRetain(item) + identities.append(item) + finally: + if result_array: + CoreFoundation.CFRelease(result_array) + + CoreFoundation.CFRelease(filedata) + + return (identities, certificates) + + +def _load_client_cert_chain(keychain, *paths): + """ + Load certificates and maybe keys from a number of files. Has the end goal + of returning a CFArray containing one SecIdentityRef, and then zero or more + SecCertificateRef objects, suitable for use as a client certificate trust + chain. + """ + # Ok, the strategy. + # + # This relies on knowing that macOS will not give you a SecIdentityRef + # unless you have imported a key into a keychain. This is a somewhat + # artificial limitation of macOS (for example, it doesn't necessarily + # affect iOS), but there is nothing inside Security.framework that lets you + # get a SecIdentityRef without having a key in a keychain. + # + # So the policy here is we take all the files and iterate them in order. + # Each one will use SecItemImport to have one or more objects loaded from + # it. We will also point at a keychain that macOS can use to work with the + # private key. + # + # Once we have all the objects, we'll check what we actually have. If we + # already have a SecIdentityRef in hand, fab: we'll use that. Otherwise, + # we'll take the first certificate (which we assume to be our leaf) and + # ask the keychain to give us a SecIdentityRef with that cert's associated + # key. + # + # We'll then return a CFArray containing the trust chain: one + # SecIdentityRef and then zero-or-more SecCertificateRef objects. The + # responsibility for freeing this CFArray will be with the caller. This + # CFArray must remain alive for the entire connection, so in practice it + # will be stored with a single SSLSocket, along with the reference to the + # keychain. + certificates = [] + identities = [] + + # Filter out bad paths. + paths = (path for path in paths if path) + + try: + for file_path in paths: + new_identities, new_certs = _load_items_from_file(keychain, file_path) + identities.extend(new_identities) + certificates.extend(new_certs) + + # Ok, we have everything. The question is: do we have an identity? If + # not, we want to grab one from the first cert we have. + if not identities: + new_identity = Security.SecIdentityRef() + status = Security.SecIdentityCreateWithCertificate( + keychain, certificates[0], ctypes.byref(new_identity) + ) + _assert_no_error(status) + identities.append(new_identity) + + # We now want to release the original certificate, as we no longer + # need it. + CoreFoundation.CFRelease(certificates.pop(0)) + + # We now need to build a new CFArray that holds the trust chain. + trust_chain = CoreFoundation.CFArrayCreateMutable( + CoreFoundation.kCFAllocatorDefault, + 0, + ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), + ) + for item in itertools.chain(identities, certificates): + # ArrayAppendValue does a CFRetain on the item. That's fine, + # because the finally block will release our other refs to them. + CoreFoundation.CFArrayAppendValue(trust_chain, item) + + return trust_chain + finally: + for obj in itertools.chain(identities, certificates): + CoreFoundation.CFRelease(obj) + + +TLS_PROTOCOL_VERSIONS = { + "SSLv2": (0, 2), + "SSLv3": (3, 0), + "TLSv1": (3, 1), + "TLSv1.1": (3, 2), + "TLSv1.2": (3, 3), +} + + +def _build_tls_unknown_ca_alert(version): + """ + Builds a TLS alert record for an unknown CA. + """ + ver_maj, ver_min = TLS_PROTOCOL_VERSIONS[version] + severity_fatal = 0x02 + description_unknown_ca = 0x30 + msg = struct.pack(">BB", severity_fatal, description_unknown_ca) + msg_len = len(msg) + record_type_alert = 0x15 + record = struct.pack(">BBBH", record_type_alert, ver_maj, ver_min, msg_len) + msg + return record diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py b/openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py new file mode 100644 index 0000000000..f91bdd6e77 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py @@ -0,0 +1,314 @@ +""" +This module provides a pool manager that uses Google App Engine's +`URLFetch Service `_. + +Example usage:: + + from urllib3 import PoolManager + from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox + + if is_appengine_sandbox(): + # AppEngineManager uses AppEngine's URLFetch API behind the scenes + http = AppEngineManager() + else: + # PoolManager uses a socket-level API behind the scenes + http = PoolManager() + + r = http.request('GET', 'https://google.com/') + +There are `limitations `_ to the URLFetch service and it may not be +the best choice for your application. There are three options for using +urllib3 on Google App Engine: + +1. You can use :class:`AppEngineManager` with URLFetch. URLFetch is + cost-effective in many circumstances as long as your usage is within the + limitations. +2. You can use a normal :class:`~urllib3.PoolManager` by enabling sockets. + Sockets also have `limitations and restrictions + `_ and have a lower free quota than URLFetch. + To use sockets, be sure to specify the following in your ``app.yaml``:: + + env_variables: + GAE_USE_SOCKETS_HTTPLIB : 'true' + +3. If you are using `App Engine Flexible +`_, you can use the standard +:class:`PoolManager` without any configuration or special environment variables. +""" + +from __future__ import absolute_import + +import io +import logging +import warnings + +from ..exceptions import ( + HTTPError, + HTTPWarning, + MaxRetryError, + ProtocolError, + SSLError, + TimeoutError, +) +from ..packages.six.moves.urllib.parse import urljoin +from ..request import RequestMethods +from ..response import HTTPResponse +from ..util.retry import Retry +from ..util.timeout import Timeout +from . import _appengine_environ + +try: + from google.appengine.api import urlfetch +except ImportError: + urlfetch = None + + +log = logging.getLogger(__name__) + + +class AppEnginePlatformWarning(HTTPWarning): + pass + + +class AppEnginePlatformError(HTTPError): + pass + + +class AppEngineManager(RequestMethods): + """ + Connection manager for Google App Engine sandbox applications. + + This manager uses the URLFetch service directly instead of using the + emulated httplib, and is subject to URLFetch limitations as described in + the App Engine documentation `here + `_. + + Notably it will raise an :class:`AppEnginePlatformError` if: + * URLFetch is not available. + * If you attempt to use this on App Engine Flexible, as full socket + support is available. + * If a request size is more than 10 megabytes. + * If a response size is more than 32 megabytes. + * If you use an unsupported request method such as OPTIONS. + + Beyond those cases, it will raise normal urllib3 errors. + """ + + def __init__( + self, + headers=None, + retries=None, + validate_certificate=True, + urlfetch_retries=True, + ): + if not urlfetch: + raise AppEnginePlatformError( + "URLFetch is not available in this environment." + ) + + warnings.warn( + "urllib3 is using URLFetch on Google App Engine sandbox instead " + "of sockets. To use sockets directly instead of URLFetch see " + "https://urllib3.readthedocs.io/en/1.26.x/reference/urllib3.contrib.html.", + AppEnginePlatformWarning, + ) + + RequestMethods.__init__(self, headers) + self.validate_certificate = validate_certificate + self.urlfetch_retries = urlfetch_retries + + self.retries = retries or Retry.DEFAULT + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Return False to re-raise any potential exceptions + return False + + def urlopen( + self, + method, + url, + body=None, + headers=None, + retries=None, + redirect=True, + timeout=Timeout.DEFAULT_TIMEOUT, + **response_kw + ): + + retries = self._get_retries(retries, redirect) + + try: + follow_redirects = redirect and retries.redirect != 0 and retries.total + response = urlfetch.fetch( + url, + payload=body, + method=method, + headers=headers or {}, + allow_truncated=False, + follow_redirects=self.urlfetch_retries and follow_redirects, + deadline=self._get_absolute_timeout(timeout), + validate_certificate=self.validate_certificate, + ) + except urlfetch.DeadlineExceededError as e: + raise TimeoutError(self, e) + + except urlfetch.InvalidURLError as e: + if "too large" in str(e): + raise AppEnginePlatformError( + "URLFetch request too large, URLFetch only " + "supports requests up to 10mb in size.", + e, + ) + raise ProtocolError(e) + + except urlfetch.DownloadError as e: + if "Too many redirects" in str(e): + raise MaxRetryError(self, url, reason=e) + raise ProtocolError(e) + + except urlfetch.ResponseTooLargeError as e: + raise AppEnginePlatformError( + "URLFetch response too large, URLFetch only supports" + "responses up to 32mb in size.", + e, + ) + + except urlfetch.SSLCertificateError as e: + raise SSLError(e) + + except urlfetch.InvalidMethodError as e: + raise AppEnginePlatformError( + "URLFetch does not support method: %s" % method, e + ) + + http_response = self._urlfetch_response_to_http_response( + response, retries=retries, **response_kw + ) + + # Handle redirect? + redirect_location = redirect and http_response.get_redirect_location() + if redirect_location: + # Check for redirect response + if self.urlfetch_retries and retries.raise_on_redirect: + raise MaxRetryError(self, url, "too many redirects") + else: + if http_response.status == 303: + method = "GET" + + try: + retries = retries.increment( + method, url, response=http_response, _pool=self + ) + except MaxRetryError: + if retries.raise_on_redirect: + raise MaxRetryError(self, url, "too many redirects") + return http_response + + retries.sleep_for_retry(http_response) + log.debug("Redirecting %s -> %s", url, redirect_location) + redirect_url = urljoin(url, redirect_location) + return self.urlopen( + method, + redirect_url, + body, + headers, + retries=retries, + redirect=redirect, + timeout=timeout, + **response_kw + ) + + # Check if we should retry the HTTP response. + has_retry_after = bool(http_response.getheader("Retry-After")) + if retries.is_retry(method, http_response.status, has_retry_after): + retries = retries.increment(method, url, response=http_response, _pool=self) + log.debug("Retry: %s", url) + retries.sleep(http_response) + return self.urlopen( + method, + url, + body=body, + headers=headers, + retries=retries, + redirect=redirect, + timeout=timeout, + **response_kw + ) + + return http_response + + def _urlfetch_response_to_http_response(self, urlfetch_resp, **response_kw): + + if is_prod_appengine(): + # Production GAE handles deflate encoding automatically, but does + # not remove the encoding header. + content_encoding = urlfetch_resp.headers.get("content-encoding") + + if content_encoding == "deflate": + del urlfetch_resp.headers["content-encoding"] + + transfer_encoding = urlfetch_resp.headers.get("transfer-encoding") + # We have a full response's content, + # so let's make sure we don't report ourselves as chunked data. + if transfer_encoding == "chunked": + encodings = transfer_encoding.split(",") + encodings.remove("chunked") + urlfetch_resp.headers["transfer-encoding"] = ",".join(encodings) + + original_response = HTTPResponse( + # In order for decoding to work, we must present the content as + # a file-like object. + body=io.BytesIO(urlfetch_resp.content), + msg=urlfetch_resp.header_msg, + headers=urlfetch_resp.headers, + status=urlfetch_resp.status_code, + **response_kw + ) + + return HTTPResponse( + body=io.BytesIO(urlfetch_resp.content), + headers=urlfetch_resp.headers, + status=urlfetch_resp.status_code, + original_response=original_response, + **response_kw + ) + + def _get_absolute_timeout(self, timeout): + if timeout is Timeout.DEFAULT_TIMEOUT: + return None # Defer to URLFetch's default. + if isinstance(timeout, Timeout): + if timeout._read is not None or timeout._connect is not None: + warnings.warn( + "URLFetch does not support granular timeout settings, " + "reverting to total or default URLFetch timeout.", + AppEnginePlatformWarning, + ) + return timeout.total + return timeout + + def _get_retries(self, retries, redirect): + if not isinstance(retries, Retry): + retries = Retry.from_int(retries, redirect=redirect, default=self.retries) + + if retries.connect or retries.read or retries.redirect: + warnings.warn( + "URLFetch only supports total retries and does not " + "recognize connect, read, or redirect retry parameters.", + AppEnginePlatformWarning, + ) + + return retries + + +# Alias methods from _appengine_environ to maintain public API interface. + +is_appengine = _appengine_environ.is_appengine +is_appengine_sandbox = _appengine_environ.is_appengine_sandbox +is_local_appengine = _appengine_environ.is_local_appengine +is_prod_appengine = _appengine_environ.is_prod_appengine +is_prod_appengine_mvms = _appengine_environ.is_prod_appengine_mvms diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py b/openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py new file mode 100644 index 0000000000..41a8fd174c --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py @@ -0,0 +1,130 @@ +""" +NTLM authenticating pool, contributed by erikcederstran + +Issue #10, see: http://code.google.com/p/urllib3/issues/detail?id=10 +""" +from __future__ import absolute_import + +import warnings +from logging import getLogger + +from ntlm import ntlm + +from .. import HTTPSConnectionPool +from ..packages.six.moves.http_client import HTTPSConnection + +warnings.warn( + "The 'urllib3.contrib.ntlmpool' module is deprecated and will be removed " + "in urllib3 v2.0 release, urllib3 is not able to support it properly due " + "to reasons listed in issue: https://github.com/urllib3/urllib3/issues/2282. " + "If you are a user of this module please comment in the mentioned issue.", + DeprecationWarning, +) + +log = getLogger(__name__) + + +class NTLMConnectionPool(HTTPSConnectionPool): + """ + Implements an NTLM authentication version of an urllib3 connection pool + """ + + scheme = "https" + + def __init__(self, user, pw, authurl, *args, **kwargs): + """ + authurl is a random URL on the server that is protected by NTLM. + user is the Windows user, probably in the DOMAIN\\username format. + pw is the password for the user. + """ + super(NTLMConnectionPool, self).__init__(*args, **kwargs) + self.authurl = authurl + self.rawuser = user + user_parts = user.split("\\", 1) + self.domain = user_parts[0].upper() + self.user = user_parts[1] + self.pw = pw + + def _new_conn(self): + # Performs the NTLM handshake that secures the connection. The socket + # must be kept open while requests are performed. + self.num_connections += 1 + log.debug( + "Starting NTLM HTTPS connection no. %d: https://%s%s", + self.num_connections, + self.host, + self.authurl, + ) + + headers = {"Connection": "Keep-Alive"} + req_header = "Authorization" + resp_header = "www-authenticate" + + conn = HTTPSConnection(host=self.host, port=self.port) + + # Send negotiation message + headers[req_header] = "NTLM %s" % ntlm.create_NTLM_NEGOTIATE_MESSAGE( + self.rawuser + ) + log.debug("Request headers: %s", headers) + conn.request("GET", self.authurl, None, headers) + res = conn.getresponse() + reshdr = dict(res.getheaders()) + log.debug("Response status: %s %s", res.status, res.reason) + log.debug("Response headers: %s", reshdr) + log.debug("Response data: %s [...]", res.read(100)) + + # Remove the reference to the socket, so that it can not be closed by + # the response object (we want to keep the socket open) + res.fp = None + + # Server should respond with a challenge message + auth_header_values = reshdr[resp_header].split(", ") + auth_header_value = None + for s in auth_header_values: + if s[:5] == "NTLM ": + auth_header_value = s[5:] + if auth_header_value is None: + raise Exception( + "Unexpected %s response header: %s" % (resp_header, reshdr[resp_header]) + ) + + # Send authentication message + ServerChallenge, NegotiateFlags = ntlm.parse_NTLM_CHALLENGE_MESSAGE( + auth_header_value + ) + auth_msg = ntlm.create_NTLM_AUTHENTICATE_MESSAGE( + ServerChallenge, self.user, self.domain, self.pw, NegotiateFlags + ) + headers[req_header] = "NTLM %s" % auth_msg + log.debug("Request headers: %s", headers) + conn.request("GET", self.authurl, None, headers) + res = conn.getresponse() + log.debug("Response status: %s %s", res.status, res.reason) + log.debug("Response headers: %s", dict(res.getheaders())) + log.debug("Response data: %s [...]", res.read()[:100]) + if res.status != 200: + if res.status == 401: + raise Exception("Server rejected request: wrong username or password") + raise Exception("Wrong server response: %s %s" % (res.status, res.reason)) + + res.fp = None + log.debug("Connection established") + return conn + + def urlopen( + self, + method, + url, + body=None, + headers=None, + retries=3, + redirect=True, + assert_same_host=True, + ): + if headers is None: + headers = {} + headers["Connection"] = "Keep-Alive" + return super(NTLMConnectionPool, self).urlopen( + method, url, body, headers, retries, redirect, assert_same_host + ) diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py b/openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py new file mode 100644 index 0000000000..def83afdb2 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py @@ -0,0 +1,511 @@ +""" +TLS with SNI_-support for Python 2. Follow these instructions if you would +like to verify TLS certificates in Python 2. Note, the default libraries do +*not* do certificate checking; you need to do additional work to validate +certificates yourself. + +This needs the following packages installed: + +* `pyOpenSSL`_ (tested with 16.0.0) +* `cryptography`_ (minimum 1.3.4, from pyopenssl) +* `idna`_ (minimum 2.0, from cryptography) + +However, pyopenssl depends on cryptography, which depends on idna, so while we +use all three directly here we end up having relatively few packages required. + +You can install them with the following command: + +.. code-block:: bash + + $ python -m pip install pyopenssl cryptography idna + +To activate certificate checking, call +:func:`~urllib3.contrib.pyopenssl.inject_into_urllib3` from your Python code +before you begin making HTTP requests. This can be done in a ``sitecustomize`` +module, or at any other time before your application begins using ``urllib3``, +like this: + +.. code-block:: python + + try: + import urllib3.contrib.pyopenssl + urllib3.contrib.pyopenssl.inject_into_urllib3() + except ImportError: + pass + +Now you can use :mod:`urllib3` as you normally would, and it will support SNI +when the required modules are installed. + +Activating this module also has the positive side effect of disabling SSL/TLS +compression in Python 2 (see `CRIME attack`_). + +.. _sni: https://en.wikipedia.org/wiki/Server_Name_Indication +.. _crime attack: https://en.wikipedia.org/wiki/CRIME_(security_exploit) +.. _pyopenssl: https://www.pyopenssl.org +.. _cryptography: https://cryptography.io +.. _idna: https://github.com/kjd/idna +""" +from __future__ import absolute_import + +import OpenSSL.SSL +from cryptography import x509 +from cryptography.hazmat.backends.openssl import backend as openssl_backend +from cryptography.hazmat.backends.openssl.x509 import _Certificate + +try: + from cryptography.x509 import UnsupportedExtension +except ImportError: + # UnsupportedExtension is gone in cryptography >= 2.1.0 + class UnsupportedExtension(Exception): + pass + + +from io import BytesIO +from socket import error as SocketError +from socket import timeout + +try: # Platform-specific: Python 2 + from socket import _fileobject +except ImportError: # Platform-specific: Python 3 + _fileobject = None + from ..packages.backports.makefile import backport_makefile + +import logging +import ssl +import sys + +from .. import util +from ..packages import six +from ..util.ssl_ import PROTOCOL_TLS_CLIENT + +__all__ = ["inject_into_urllib3", "extract_from_urllib3"] + +# SNI always works. +HAS_SNI = True + +# Map from urllib3 to PyOpenSSL compatible parameter-values. +_openssl_versions = { + util.PROTOCOL_TLS: OpenSSL.SSL.SSLv23_METHOD, + PROTOCOL_TLS_CLIENT: OpenSSL.SSL.SSLv23_METHOD, + ssl.PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD, +} + +if hasattr(ssl, "PROTOCOL_SSLv3") and hasattr(OpenSSL.SSL, "SSLv3_METHOD"): + _openssl_versions[ssl.PROTOCOL_SSLv3] = OpenSSL.SSL.SSLv3_METHOD + +if hasattr(ssl, "PROTOCOL_TLSv1_1") and hasattr(OpenSSL.SSL, "TLSv1_1_METHOD"): + _openssl_versions[ssl.PROTOCOL_TLSv1_1] = OpenSSL.SSL.TLSv1_1_METHOD + +if hasattr(ssl, "PROTOCOL_TLSv1_2") and hasattr(OpenSSL.SSL, "TLSv1_2_METHOD"): + _openssl_versions[ssl.PROTOCOL_TLSv1_2] = OpenSSL.SSL.TLSv1_2_METHOD + + +_stdlib_to_openssl_verify = { + ssl.CERT_NONE: OpenSSL.SSL.VERIFY_NONE, + ssl.CERT_OPTIONAL: OpenSSL.SSL.VERIFY_PEER, + ssl.CERT_REQUIRED: OpenSSL.SSL.VERIFY_PEER + + OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT, +} +_openssl_to_stdlib_verify = dict((v, k) for k, v in _stdlib_to_openssl_verify.items()) + +# OpenSSL will only write 16K at a time +SSL_WRITE_BLOCKSIZE = 16384 + +orig_util_HAS_SNI = util.HAS_SNI +orig_util_SSLContext = util.ssl_.SSLContext + + +log = logging.getLogger(__name__) + + +def inject_into_urllib3(): + "Monkey-patch urllib3 with PyOpenSSL-backed SSL-support." + + _validate_dependencies_met() + + util.SSLContext = PyOpenSSLContext + util.ssl_.SSLContext = PyOpenSSLContext + util.HAS_SNI = HAS_SNI + util.ssl_.HAS_SNI = HAS_SNI + util.IS_PYOPENSSL = True + util.ssl_.IS_PYOPENSSL = True + + +def extract_from_urllib3(): + "Undo monkey-patching by :func:`inject_into_urllib3`." + + util.SSLContext = orig_util_SSLContext + util.ssl_.SSLContext = orig_util_SSLContext + util.HAS_SNI = orig_util_HAS_SNI + util.ssl_.HAS_SNI = orig_util_HAS_SNI + util.IS_PYOPENSSL = False + util.ssl_.IS_PYOPENSSL = False + + +def _validate_dependencies_met(): + """ + Verifies that PyOpenSSL's package-level dependencies have been met. + Throws `ImportError` if they are not met. + """ + # Method added in `cryptography==1.1`; not available in older versions + from cryptography.x509.extensions import Extensions + + if getattr(Extensions, "get_extension_for_class", None) is None: + raise ImportError( + "'cryptography' module missing required functionality. " + "Try upgrading to v1.3.4 or newer." + ) + + # pyOpenSSL 0.14 and above use cryptography for OpenSSL bindings. The _x509 + # attribute is only present on those versions. + from OpenSSL.crypto import X509 + + x509 = X509() + if getattr(x509, "_x509", None) is None: + raise ImportError( + "'pyOpenSSL' module missing required functionality. " + "Try upgrading to v0.14 or newer." + ) + + +def _dnsname_to_stdlib(name): + """ + Converts a dNSName SubjectAlternativeName field to the form used by the + standard library on the given Python version. + + Cryptography produces a dNSName as a unicode string that was idna-decoded + from ASCII bytes. We need to idna-encode that string to get it back, and + then on Python 3 we also need to convert to unicode via UTF-8 (the stdlib + uses PyUnicode_FromStringAndSize on it, which decodes via UTF-8). + + If the name cannot be idna-encoded then we return None signalling that + the name given should be skipped. + """ + + def idna_encode(name): + """ + Borrowed wholesale from the Python Cryptography Project. It turns out + that we can't just safely call `idna.encode`: it can explode for + wildcard names. This avoids that problem. + """ + import idna + + try: + for prefix in [u"*.", u"."]: + if name.startswith(prefix): + name = name[len(prefix) :] + return prefix.encode("ascii") + idna.encode(name) + return idna.encode(name) + except idna.core.IDNAError: + return None + + # Don't send IPv6 addresses through the IDNA encoder. + if ":" in name: + return name + + name = idna_encode(name) + if name is None: + return None + elif sys.version_info >= (3, 0): + name = name.decode("utf-8") + return name + + +def get_subj_alt_name(peer_cert): + """ + Given an PyOpenSSL certificate, provides all the subject alternative names. + """ + # Pass the cert to cryptography, which has much better APIs for this. + if hasattr(peer_cert, "to_cryptography"): + cert = peer_cert.to_cryptography() + else: + # This is technically using private APIs, but should work across all + # relevant versions before PyOpenSSL got a proper API for this. + cert = _Certificate(openssl_backend, peer_cert._x509) + + # We want to find the SAN extension. Ask Cryptography to locate it (it's + # faster than looping in Python) + try: + ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + except x509.ExtensionNotFound: + # No such extension, return the empty list. + return [] + except ( + x509.DuplicateExtension, + UnsupportedExtension, + x509.UnsupportedGeneralNameType, + UnicodeError, + ) as e: + # A problem has been found with the quality of the certificate. Assume + # no SAN field is present. + log.warning( + "A problem was encountered with the certificate that prevented " + "urllib3 from finding the SubjectAlternativeName field. This can " + "affect certificate validation. The error was %s", + e, + ) + return [] + + # We want to return dNSName and iPAddress fields. We need to cast the IPs + # back to strings because the match_hostname function wants them as + # strings. + # Sadly the DNS names need to be idna encoded and then, on Python 3, UTF-8 + # decoded. This is pretty frustrating, but that's what the standard library + # does with certificates, and so we need to attempt to do the same. + # We also want to skip over names which cannot be idna encoded. + names = [ + ("DNS", name) + for name in map(_dnsname_to_stdlib, ext.get_values_for_type(x509.DNSName)) + if name is not None + ] + names.extend( + ("IP Address", str(name)) for name in ext.get_values_for_type(x509.IPAddress) + ) + + return names + + +class WrappedSocket(object): + """API-compatibility wrapper for Python OpenSSL's Connection-class. + + Note: _makefile_refs, _drop() and _reuse() are needed for the garbage + collector of pypy. + """ + + def __init__(self, connection, socket, suppress_ragged_eofs=True): + self.connection = connection + self.socket = socket + self.suppress_ragged_eofs = suppress_ragged_eofs + self._makefile_refs = 0 + self._closed = False + + def fileno(self): + return self.socket.fileno() + + # Copy-pasted from Python 3.5 source code + def _decref_socketios(self): + if self._makefile_refs > 0: + self._makefile_refs -= 1 + if self._closed: + self.close() + + def recv(self, *args, **kwargs): + try: + data = self.connection.recv(*args, **kwargs) + except OpenSSL.SSL.SysCallError as e: + if self.suppress_ragged_eofs and e.args == (-1, "Unexpected EOF"): + return b"" + else: + raise SocketError(str(e)) + except OpenSSL.SSL.ZeroReturnError: + if self.connection.get_shutdown() == OpenSSL.SSL.RECEIVED_SHUTDOWN: + return b"" + else: + raise + except OpenSSL.SSL.WantReadError: + if not util.wait_for_read(self.socket, self.socket.gettimeout()): + raise timeout("The read operation timed out") + else: + return self.recv(*args, **kwargs) + + # TLS 1.3 post-handshake authentication + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("read error: %r" % e) + else: + return data + + def recv_into(self, *args, **kwargs): + try: + return self.connection.recv_into(*args, **kwargs) + except OpenSSL.SSL.SysCallError as e: + if self.suppress_ragged_eofs and e.args == (-1, "Unexpected EOF"): + return 0 + else: + raise SocketError(str(e)) + except OpenSSL.SSL.ZeroReturnError: + if self.connection.get_shutdown() == OpenSSL.SSL.RECEIVED_SHUTDOWN: + return 0 + else: + raise + except OpenSSL.SSL.WantReadError: + if not util.wait_for_read(self.socket, self.socket.gettimeout()): + raise timeout("The read operation timed out") + else: + return self.recv_into(*args, **kwargs) + + # TLS 1.3 post-handshake authentication + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("read error: %r" % e) + + def settimeout(self, timeout): + return self.socket.settimeout(timeout) + + def _send_until_done(self, data): + while True: + try: + return self.connection.send(data) + except OpenSSL.SSL.WantWriteError: + if not util.wait_for_write(self.socket, self.socket.gettimeout()): + raise timeout() + continue + except OpenSSL.SSL.SysCallError as e: + raise SocketError(str(e)) + + def sendall(self, data): + total_sent = 0 + while total_sent < len(data): + sent = self._send_until_done( + data[total_sent : total_sent + SSL_WRITE_BLOCKSIZE] + ) + total_sent += sent + + def shutdown(self): + # FIXME rethrow compatible exceptions should we ever use this + self.connection.shutdown() + + def close(self): + if self._makefile_refs < 1: + try: + self._closed = True + return self.connection.close() + except OpenSSL.SSL.Error: + return + else: + self._makefile_refs -= 1 + + def getpeercert(self, binary_form=False): + x509 = self.connection.get_peer_certificate() + + if not x509: + return x509 + + if binary_form: + return OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, x509) + + return { + "subject": ((("commonName", x509.get_subject().CN),),), + "subjectAltName": get_subj_alt_name(x509), + } + + def version(self): + return self.connection.get_protocol_version_name() + + def _reuse(self): + self._makefile_refs += 1 + + def _drop(self): + if self._makefile_refs < 1: + self.close() + else: + self._makefile_refs -= 1 + + +if _fileobject: # Platform-specific: Python 2 + + def makefile(self, mode, bufsize=-1): + self._makefile_refs += 1 + return _fileobject(self, mode, bufsize, close=True) + + +else: # Platform-specific: Python 3 + makefile = backport_makefile + +WrappedSocket.makefile = makefile + + +class PyOpenSSLContext(object): + """ + I am a wrapper class for the PyOpenSSL ``Context`` object. I am responsible + for translating the interface of the standard library ``SSLContext`` object + to calls into PyOpenSSL. + """ + + def __init__(self, protocol): + self.protocol = _openssl_versions[protocol] + self._ctx = OpenSSL.SSL.Context(self.protocol) + self._options = 0 + self.check_hostname = False + + @property + def options(self): + return self._options + + @options.setter + def options(self, value): + self._options = value + self._ctx.set_options(value) + + @property + def verify_mode(self): + return _openssl_to_stdlib_verify[self._ctx.get_verify_mode()] + + @verify_mode.setter + def verify_mode(self, value): + self._ctx.set_verify(_stdlib_to_openssl_verify[value], _verify_callback) + + def set_default_verify_paths(self): + self._ctx.set_default_verify_paths() + + def set_ciphers(self, ciphers): + if isinstance(ciphers, six.text_type): + ciphers = ciphers.encode("utf-8") + self._ctx.set_cipher_list(ciphers) + + def load_verify_locations(self, cafile=None, capath=None, cadata=None): + if cafile is not None: + cafile = cafile.encode("utf-8") + if capath is not None: + capath = capath.encode("utf-8") + try: + self._ctx.load_verify_locations(cafile, capath) + if cadata is not None: + self._ctx.load_verify_locations(BytesIO(cadata)) + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("unable to load trusted certificates: %r" % e) + + def load_cert_chain(self, certfile, keyfile=None, password=None): + self._ctx.use_certificate_chain_file(certfile) + if password is not None: + if not isinstance(password, six.binary_type): + password = password.encode("utf-8") + self._ctx.set_passwd_cb(lambda *_: password) + self._ctx.use_privatekey_file(keyfile or certfile) + + def set_alpn_protocols(self, protocols): + protocols = [six.ensure_binary(p) for p in protocols] + return self._ctx.set_alpn_protos(protocols) + + def wrap_socket( + self, + sock, + server_side=False, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + server_hostname=None, + ): + cnx = OpenSSL.SSL.Connection(self._ctx, sock) + + if isinstance(server_hostname, six.text_type): # Platform-specific: Python 3 + server_hostname = server_hostname.encode("utf-8") + + if server_hostname is not None: + cnx.set_tlsext_host_name(server_hostname) + + cnx.set_connect_state() + + while True: + try: + cnx.do_handshake() + except OpenSSL.SSL.WantReadError: + if not util.wait_for_read(sock, sock.gettimeout()): + raise timeout("select timed out") + continue + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("bad handshake: %r" % e) + break + + return WrappedSocket(cnx, sock) + + +def _verify_callback(cnx, x509, err_no, err_depth, return_code): + return err_no == 0 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py b/openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py new file mode 100644 index 0000000000..554c015fed --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py @@ -0,0 +1,922 @@ +""" +SecureTranport support for urllib3 via ctypes. + +This makes platform-native TLS available to urllib3 users on macOS without the +use of a compiler. This is an important feature because the Python Package +Index is moving to become a TLSv1.2-or-higher server, and the default OpenSSL +that ships with macOS is not capable of doing TLSv1.2. The only way to resolve +this is to give macOS users an alternative solution to the problem, and that +solution is to use SecureTransport. + +We use ctypes here because this solution must not require a compiler. That's +because pip is not allowed to require a compiler either. + +This is not intended to be a seriously long-term solution to this problem. +The hope is that PEP 543 will eventually solve this issue for us, at which +point we can retire this contrib module. But in the short term, we need to +solve the impending tire fire that is Python on Mac without this kind of +contrib module. So...here we are. + +To use this module, simply import and inject it:: + + import urllib3.contrib.securetransport + urllib3.contrib.securetransport.inject_into_urllib3() + +Happy TLSing! + +This code is a bastardised version of the code found in Will Bond's oscrypto +library. An enormous debt is owed to him for blazing this trail for us. For +that reason, this code should be considered to be covered both by urllib3's +license and by oscrypto's: + +.. code-block:: + + Copyright (c) 2015-2016 Will Bond + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +""" +from __future__ import absolute_import + +import contextlib +import ctypes +import errno +import os.path +import shutil +import socket +import ssl +import struct +import threading +import weakref + +import six + +from .. import util +from ..util.ssl_ import PROTOCOL_TLS_CLIENT +from ._securetransport.bindings import CoreFoundation, Security, SecurityConst +from ._securetransport.low_level import ( + _assert_no_error, + _build_tls_unknown_ca_alert, + _cert_array_from_pem, + _create_cfstring_array, + _load_client_cert_chain, + _temporary_keychain, +) + +try: # Platform-specific: Python 2 + from socket import _fileobject +except ImportError: # Platform-specific: Python 3 + _fileobject = None + from ..packages.backports.makefile import backport_makefile + +__all__ = ["inject_into_urllib3", "extract_from_urllib3"] + +# SNI always works +HAS_SNI = True + +orig_util_HAS_SNI = util.HAS_SNI +orig_util_SSLContext = util.ssl_.SSLContext + +# This dictionary is used by the read callback to obtain a handle to the +# calling wrapped socket. This is a pretty silly approach, but for now it'll +# do. I feel like I should be able to smuggle a handle to the wrapped socket +# directly in the SSLConnectionRef, but for now this approach will work I +# guess. +# +# We need to lock around this structure for inserts, but we don't do it for +# reads/writes in the callbacks. The reasoning here goes as follows: +# +# 1. It is not possible to call into the callbacks before the dictionary is +# populated, so once in the callback the id must be in the dictionary. +# 2. The callbacks don't mutate the dictionary, they only read from it, and +# so cannot conflict with any of the insertions. +# +# This is good: if we had to lock in the callbacks we'd drastically slow down +# the performance of this code. +_connection_refs = weakref.WeakValueDictionary() +_connection_ref_lock = threading.Lock() + +# Limit writes to 16kB. This is OpenSSL's limit, but we'll cargo-cult it over +# for no better reason than we need *a* limit, and this one is right there. +SSL_WRITE_BLOCKSIZE = 16384 + +# This is our equivalent of util.ssl_.DEFAULT_CIPHERS, but expanded out to +# individual cipher suites. We need to do this because this is how +# SecureTransport wants them. +CIPHER_SUITES = [ + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + SecurityConst.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + SecurityConst.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + SecurityConst.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256, + SecurityConst.TLS_DHE_RSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_DHE_RSA_WITH_AES_128_CBC_SHA, + SecurityConst.TLS_AES_256_GCM_SHA384, + SecurityConst.TLS_AES_128_GCM_SHA256, + SecurityConst.TLS_RSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_RSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_AES_128_CCM_8_SHA256, + SecurityConst.TLS_AES_128_CCM_SHA256, + SecurityConst.TLS_RSA_WITH_AES_256_CBC_SHA256, + SecurityConst.TLS_RSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_RSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_RSA_WITH_AES_128_CBC_SHA, +] + +# Basically this is simple: for PROTOCOL_SSLv23 we turn it into a low of +# TLSv1 and a high of TLSv1.2. For everything else, we pin to that version. +# TLSv1 to 1.2 are supported on macOS 10.8+ +_protocol_to_min_max = { + util.PROTOCOL_TLS: (SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol12), + PROTOCOL_TLS_CLIENT: (SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol12), +} + +if hasattr(ssl, "PROTOCOL_SSLv2"): + _protocol_to_min_max[ssl.PROTOCOL_SSLv2] = ( + SecurityConst.kSSLProtocol2, + SecurityConst.kSSLProtocol2, + ) +if hasattr(ssl, "PROTOCOL_SSLv3"): + _protocol_to_min_max[ssl.PROTOCOL_SSLv3] = ( + SecurityConst.kSSLProtocol3, + SecurityConst.kSSLProtocol3, + ) +if hasattr(ssl, "PROTOCOL_TLSv1"): + _protocol_to_min_max[ssl.PROTOCOL_TLSv1] = ( + SecurityConst.kTLSProtocol1, + SecurityConst.kTLSProtocol1, + ) +if hasattr(ssl, "PROTOCOL_TLSv1_1"): + _protocol_to_min_max[ssl.PROTOCOL_TLSv1_1] = ( + SecurityConst.kTLSProtocol11, + SecurityConst.kTLSProtocol11, + ) +if hasattr(ssl, "PROTOCOL_TLSv1_2"): + _protocol_to_min_max[ssl.PROTOCOL_TLSv1_2] = ( + SecurityConst.kTLSProtocol12, + SecurityConst.kTLSProtocol12, + ) + + +def inject_into_urllib3(): + """ + Monkey-patch urllib3 with SecureTransport-backed SSL-support. + """ + util.SSLContext = SecureTransportContext + util.ssl_.SSLContext = SecureTransportContext + util.HAS_SNI = HAS_SNI + util.ssl_.HAS_SNI = HAS_SNI + util.IS_SECURETRANSPORT = True + util.ssl_.IS_SECURETRANSPORT = True + + +def extract_from_urllib3(): + """ + Undo monkey-patching by :func:`inject_into_urllib3`. + """ + util.SSLContext = orig_util_SSLContext + util.ssl_.SSLContext = orig_util_SSLContext + util.HAS_SNI = orig_util_HAS_SNI + util.ssl_.HAS_SNI = orig_util_HAS_SNI + util.IS_SECURETRANSPORT = False + util.ssl_.IS_SECURETRANSPORT = False + + +def _read_callback(connection_id, data_buffer, data_length_pointer): + """ + SecureTransport read callback. This is called by ST to request that data + be returned from the socket. + """ + wrapped_socket = None + try: + wrapped_socket = _connection_refs.get(connection_id) + if wrapped_socket is None: + return SecurityConst.errSSLInternal + base_socket = wrapped_socket.socket + + requested_length = data_length_pointer[0] + + timeout = wrapped_socket.gettimeout() + error = None + read_count = 0 + + try: + while read_count < requested_length: + if timeout is None or timeout >= 0: + if not util.wait_for_read(base_socket, timeout): + raise socket.error(errno.EAGAIN, "timed out") + + remaining = requested_length - read_count + buffer = (ctypes.c_char * remaining).from_address( + data_buffer + read_count + ) + chunk_size = base_socket.recv_into(buffer, remaining) + read_count += chunk_size + if not chunk_size: + if not read_count: + return SecurityConst.errSSLClosedGraceful + break + except (socket.error) as e: + error = e.errno + + if error is not None and error != errno.EAGAIN: + data_length_pointer[0] = read_count + if error == errno.ECONNRESET or error == errno.EPIPE: + return SecurityConst.errSSLClosedAbort + raise + + data_length_pointer[0] = read_count + + if read_count != requested_length: + return SecurityConst.errSSLWouldBlock + + return 0 + except Exception as e: + if wrapped_socket is not None: + wrapped_socket._exception = e + return SecurityConst.errSSLInternal + + +def _write_callback(connection_id, data_buffer, data_length_pointer): + """ + SecureTransport write callback. This is called by ST to request that data + actually be sent on the network. + """ + wrapped_socket = None + try: + wrapped_socket = _connection_refs.get(connection_id) + if wrapped_socket is None: + return SecurityConst.errSSLInternal + base_socket = wrapped_socket.socket + + bytes_to_write = data_length_pointer[0] + data = ctypes.string_at(data_buffer, bytes_to_write) + + timeout = wrapped_socket.gettimeout() + error = None + sent = 0 + + try: + while sent < bytes_to_write: + if timeout is None or timeout >= 0: + if not util.wait_for_write(base_socket, timeout): + raise socket.error(errno.EAGAIN, "timed out") + chunk_sent = base_socket.send(data) + sent += chunk_sent + + # This has some needless copying here, but I'm not sure there's + # much value in optimising this data path. + data = data[chunk_sent:] + except (socket.error) as e: + error = e.errno + + if error is not None and error != errno.EAGAIN: + data_length_pointer[0] = sent + if error == errno.ECONNRESET or error == errno.EPIPE: + return SecurityConst.errSSLClosedAbort + raise + + data_length_pointer[0] = sent + + if sent != bytes_to_write: + return SecurityConst.errSSLWouldBlock + + return 0 + except Exception as e: + if wrapped_socket is not None: + wrapped_socket._exception = e + return SecurityConst.errSSLInternal + + +# We need to keep these two objects references alive: if they get GC'd while +# in use then SecureTransport could attempt to call a function that is in freed +# memory. That would be...uh...bad. Yeah, that's the word. Bad. +_read_callback_pointer = Security.SSLReadFunc(_read_callback) +_write_callback_pointer = Security.SSLWriteFunc(_write_callback) + + +class WrappedSocket(object): + """ + API-compatibility wrapper for Python's OpenSSL wrapped socket object. + + Note: _makefile_refs, _drop(), and _reuse() are needed for the garbage + collector of PyPy. + """ + + def __init__(self, socket): + self.socket = socket + self.context = None + self._makefile_refs = 0 + self._closed = False + self._exception = None + self._keychain = None + self._keychain_dir = None + self._client_cert_chain = None + + # We save off the previously-configured timeout and then set it to + # zero. This is done because we use select and friends to handle the + # timeouts, but if we leave the timeout set on the lower socket then + # Python will "kindly" call select on that socket again for us. Avoid + # that by forcing the timeout to zero. + self._timeout = self.socket.gettimeout() + self.socket.settimeout(0) + + @contextlib.contextmanager + def _raise_on_error(self): + """ + A context manager that can be used to wrap calls that do I/O from + SecureTransport. If any of the I/O callbacks hit an exception, this + context manager will correctly propagate the exception after the fact. + This avoids silently swallowing those exceptions. + + It also correctly forces the socket closed. + """ + self._exception = None + + # We explicitly don't catch around this yield because in the unlikely + # event that an exception was hit in the block we don't want to swallow + # it. + yield + if self._exception is not None: + exception, self._exception = self._exception, None + self.close() + raise exception + + def _set_ciphers(self): + """ + Sets up the allowed ciphers. By default this matches the set in + util.ssl_.DEFAULT_CIPHERS, at least as supported by macOS. This is done + custom and doesn't allow changing at this time, mostly because parsing + OpenSSL cipher strings is going to be a freaking nightmare. + """ + ciphers = (Security.SSLCipherSuite * len(CIPHER_SUITES))(*CIPHER_SUITES) + result = Security.SSLSetEnabledCiphers( + self.context, ciphers, len(CIPHER_SUITES) + ) + _assert_no_error(result) + + def _set_alpn_protocols(self, protocols): + """ + Sets up the ALPN protocols on the context. + """ + if not protocols: + return + protocols_arr = _create_cfstring_array(protocols) + try: + result = Security.SSLSetALPNProtocols(self.context, protocols_arr) + _assert_no_error(result) + finally: + CoreFoundation.CFRelease(protocols_arr) + + def _custom_validate(self, verify, trust_bundle): + """ + Called when we have set custom validation. We do this in two cases: + first, when cert validation is entirely disabled; and second, when + using a custom trust DB. + Raises an SSLError if the connection is not trusted. + """ + # If we disabled cert validation, just say: cool. + if not verify: + return + + successes = ( + SecurityConst.kSecTrustResultUnspecified, + SecurityConst.kSecTrustResultProceed, + ) + try: + trust_result = self._evaluate_trust(trust_bundle) + if trust_result in successes: + return + reason = "error code: %d" % (trust_result,) + except Exception as e: + # Do not trust on error + reason = "exception: %r" % (e,) + + # SecureTransport does not send an alert nor shuts down the connection. + rec = _build_tls_unknown_ca_alert(self.version()) + self.socket.sendall(rec) + # close the connection immediately + # l_onoff = 1, activate linger + # l_linger = 0, linger for 0 seoncds + opts = struct.pack("ii", 1, 0) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, opts) + self.close() + raise ssl.SSLError("certificate verify failed, %s" % reason) + + def _evaluate_trust(self, trust_bundle): + # We want data in memory, so load it up. + if os.path.isfile(trust_bundle): + with open(trust_bundle, "rb") as f: + trust_bundle = f.read() + + cert_array = None + trust = Security.SecTrustRef() + + try: + # Get a CFArray that contains the certs we want. + cert_array = _cert_array_from_pem(trust_bundle) + + # Ok, now the hard part. We want to get the SecTrustRef that ST has + # created for this connection, shove our CAs into it, tell ST to + # ignore everything else it knows, and then ask if it can build a + # chain. This is a buuuunch of code. + result = Security.SSLCopyPeerTrust(self.context, ctypes.byref(trust)) + _assert_no_error(result) + if not trust: + raise ssl.SSLError("Failed to copy trust reference") + + result = Security.SecTrustSetAnchorCertificates(trust, cert_array) + _assert_no_error(result) + + result = Security.SecTrustSetAnchorCertificatesOnly(trust, True) + _assert_no_error(result) + + trust_result = Security.SecTrustResultType() + result = Security.SecTrustEvaluate(trust, ctypes.byref(trust_result)) + _assert_no_error(result) + finally: + if trust: + CoreFoundation.CFRelease(trust) + + if cert_array is not None: + CoreFoundation.CFRelease(cert_array) + + return trust_result.value + + def handshake( + self, + server_hostname, + verify, + trust_bundle, + min_version, + max_version, + client_cert, + client_key, + client_key_passphrase, + alpn_protocols, + ): + """ + Actually performs the TLS handshake. This is run automatically by + wrapped socket, and shouldn't be needed in user code. + """ + # First, we do the initial bits of connection setup. We need to create + # a context, set its I/O funcs, and set the connection reference. + self.context = Security.SSLCreateContext( + None, SecurityConst.kSSLClientSide, SecurityConst.kSSLStreamType + ) + result = Security.SSLSetIOFuncs( + self.context, _read_callback_pointer, _write_callback_pointer + ) + _assert_no_error(result) + + # Here we need to compute the handle to use. We do this by taking the + # id of self modulo 2**31 - 1. If this is already in the dictionary, we + # just keep incrementing by one until we find a free space. + with _connection_ref_lock: + handle = id(self) % 2147483647 + while handle in _connection_refs: + handle = (handle + 1) % 2147483647 + _connection_refs[handle] = self + + result = Security.SSLSetConnection(self.context, handle) + _assert_no_error(result) + + # If we have a server hostname, we should set that too. + if server_hostname: + if not isinstance(server_hostname, bytes): + server_hostname = server_hostname.encode("utf-8") + + result = Security.SSLSetPeerDomainName( + self.context, server_hostname, len(server_hostname) + ) + _assert_no_error(result) + + # Setup the ciphers. + self._set_ciphers() + + # Setup the ALPN protocols. + self._set_alpn_protocols(alpn_protocols) + + # Set the minimum and maximum TLS versions. + result = Security.SSLSetProtocolVersionMin(self.context, min_version) + _assert_no_error(result) + + result = Security.SSLSetProtocolVersionMax(self.context, max_version) + _assert_no_error(result) + + # If there's a trust DB, we need to use it. We do that by telling + # SecureTransport to break on server auth. We also do that if we don't + # want to validate the certs at all: we just won't actually do any + # authing in that case. + if not verify or trust_bundle is not None: + result = Security.SSLSetSessionOption( + self.context, SecurityConst.kSSLSessionOptionBreakOnServerAuth, True + ) + _assert_no_error(result) + + # If there's a client cert, we need to use it. + if client_cert: + self._keychain, self._keychain_dir = _temporary_keychain() + self._client_cert_chain = _load_client_cert_chain( + self._keychain, client_cert, client_key + ) + result = Security.SSLSetCertificate(self.context, self._client_cert_chain) + _assert_no_error(result) + + while True: + with self._raise_on_error(): + result = Security.SSLHandshake(self.context) + + if result == SecurityConst.errSSLWouldBlock: + raise socket.timeout("handshake timed out") + elif result == SecurityConst.errSSLServerAuthCompleted: + self._custom_validate(verify, trust_bundle) + continue + else: + _assert_no_error(result) + break + + def fileno(self): + return self.socket.fileno() + + # Copy-pasted from Python 3.5 source code + def _decref_socketios(self): + if self._makefile_refs > 0: + self._makefile_refs -= 1 + if self._closed: + self.close() + + def recv(self, bufsiz): + buffer = ctypes.create_string_buffer(bufsiz) + bytes_read = self.recv_into(buffer, bufsiz) + data = buffer[:bytes_read] + return data + + def recv_into(self, buffer, nbytes=None): + # Read short on EOF. + if self._closed: + return 0 + + if nbytes is None: + nbytes = len(buffer) + + buffer = (ctypes.c_char * nbytes).from_buffer(buffer) + processed_bytes = ctypes.c_size_t(0) + + with self._raise_on_error(): + result = Security.SSLRead( + self.context, buffer, nbytes, ctypes.byref(processed_bytes) + ) + + # There are some result codes that we want to treat as "not always + # errors". Specifically, those are errSSLWouldBlock, + # errSSLClosedGraceful, and errSSLClosedNoNotify. + if result == SecurityConst.errSSLWouldBlock: + # If we didn't process any bytes, then this was just a time out. + # However, we can get errSSLWouldBlock in situations when we *did* + # read some data, and in those cases we should just read "short" + # and return. + if processed_bytes.value == 0: + # Timed out, no data read. + raise socket.timeout("recv timed out") + elif result in ( + SecurityConst.errSSLClosedGraceful, + SecurityConst.errSSLClosedNoNotify, + ): + # The remote peer has closed this connection. We should do so as + # well. Note that we don't actually return here because in + # principle this could actually be fired along with return data. + # It's unlikely though. + self.close() + else: + _assert_no_error(result) + + # Ok, we read and probably succeeded. We should return whatever data + # was actually read. + return processed_bytes.value + + def settimeout(self, timeout): + self._timeout = timeout + + def gettimeout(self): + return self._timeout + + def send(self, data): + processed_bytes = ctypes.c_size_t(0) + + with self._raise_on_error(): + result = Security.SSLWrite( + self.context, data, len(data), ctypes.byref(processed_bytes) + ) + + if result == SecurityConst.errSSLWouldBlock and processed_bytes.value == 0: + # Timed out + raise socket.timeout("send timed out") + else: + _assert_no_error(result) + + # We sent, and probably succeeded. Tell them how much we sent. + return processed_bytes.value + + def sendall(self, data): + total_sent = 0 + while total_sent < len(data): + sent = self.send(data[total_sent : total_sent + SSL_WRITE_BLOCKSIZE]) + total_sent += sent + + def shutdown(self): + with self._raise_on_error(): + Security.SSLClose(self.context) + + def close(self): + # TODO: should I do clean shutdown here? Do I have to? + if self._makefile_refs < 1: + self._closed = True + if self.context: + CoreFoundation.CFRelease(self.context) + self.context = None + if self._client_cert_chain: + CoreFoundation.CFRelease(self._client_cert_chain) + self._client_cert_chain = None + if self._keychain: + Security.SecKeychainDelete(self._keychain) + CoreFoundation.CFRelease(self._keychain) + shutil.rmtree(self._keychain_dir) + self._keychain = self._keychain_dir = None + return self.socket.close() + else: + self._makefile_refs -= 1 + + def getpeercert(self, binary_form=False): + # Urgh, annoying. + # + # Here's how we do this: + # + # 1. Call SSLCopyPeerTrust to get hold of the trust object for this + # connection. + # 2. Call SecTrustGetCertificateAtIndex for index 0 to get the leaf. + # 3. To get the CN, call SecCertificateCopyCommonName and process that + # string so that it's of the appropriate type. + # 4. To get the SAN, we need to do something a bit more complex: + # a. Call SecCertificateCopyValues to get the data, requesting + # kSecOIDSubjectAltName. + # b. Mess about with this dictionary to try to get the SANs out. + # + # This is gross. Really gross. It's going to be a few hundred LoC extra + # just to repeat something that SecureTransport can *already do*. So my + # operating assumption at this time is that what we want to do is + # instead to just flag to urllib3 that it shouldn't do its own hostname + # validation when using SecureTransport. + if not binary_form: + raise ValueError("SecureTransport only supports dumping binary certs") + trust = Security.SecTrustRef() + certdata = None + der_bytes = None + + try: + # Grab the trust store. + result = Security.SSLCopyPeerTrust(self.context, ctypes.byref(trust)) + _assert_no_error(result) + if not trust: + # Probably we haven't done the handshake yet. No biggie. + return None + + cert_count = Security.SecTrustGetCertificateCount(trust) + if not cert_count: + # Also a case that might happen if we haven't handshaked. + # Handshook? Handshaken? + return None + + leaf = Security.SecTrustGetCertificateAtIndex(trust, 0) + assert leaf + + # Ok, now we want the DER bytes. + certdata = Security.SecCertificateCopyData(leaf) + assert certdata + + data_length = CoreFoundation.CFDataGetLength(certdata) + data_buffer = CoreFoundation.CFDataGetBytePtr(certdata) + der_bytes = ctypes.string_at(data_buffer, data_length) + finally: + if certdata: + CoreFoundation.CFRelease(certdata) + if trust: + CoreFoundation.CFRelease(trust) + + return der_bytes + + def version(self): + protocol = Security.SSLProtocol() + result = Security.SSLGetNegotiatedProtocolVersion( + self.context, ctypes.byref(protocol) + ) + _assert_no_error(result) + if protocol.value == SecurityConst.kTLSProtocol13: + raise ssl.SSLError("SecureTransport does not support TLS 1.3") + elif protocol.value == SecurityConst.kTLSProtocol12: + return "TLSv1.2" + elif protocol.value == SecurityConst.kTLSProtocol11: + return "TLSv1.1" + elif protocol.value == SecurityConst.kTLSProtocol1: + return "TLSv1" + elif protocol.value == SecurityConst.kSSLProtocol3: + return "SSLv3" + elif protocol.value == SecurityConst.kSSLProtocol2: + return "SSLv2" + else: + raise ssl.SSLError("Unknown TLS version: %r" % protocol) + + def _reuse(self): + self._makefile_refs += 1 + + def _drop(self): + if self._makefile_refs < 1: + self.close() + else: + self._makefile_refs -= 1 + + +if _fileobject: # Platform-specific: Python 2 + + def makefile(self, mode, bufsize=-1): + self._makefile_refs += 1 + return _fileobject(self, mode, bufsize, close=True) + + +else: # Platform-specific: Python 3 + + def makefile(self, mode="r", buffering=None, *args, **kwargs): + # We disable buffering with SecureTransport because it conflicts with + # the buffering that ST does internally (see issue #1153 for more). + buffering = 0 + return backport_makefile(self, mode, buffering, *args, **kwargs) + + +WrappedSocket.makefile = makefile + + +class SecureTransportContext(object): + """ + I am a wrapper class for the SecureTransport library, to translate the + interface of the standard library ``SSLContext`` object to calls into + SecureTransport. + """ + + def __init__(self, protocol): + self._min_version, self._max_version = _protocol_to_min_max[protocol] + self._options = 0 + self._verify = False + self._trust_bundle = None + self._client_cert = None + self._client_key = None + self._client_key_passphrase = None + self._alpn_protocols = None + + @property + def check_hostname(self): + """ + SecureTransport cannot have its hostname checking disabled. For more, + see the comment on getpeercert() in this file. + """ + return True + + @check_hostname.setter + def check_hostname(self, value): + """ + SecureTransport cannot have its hostname checking disabled. For more, + see the comment on getpeercert() in this file. + """ + pass + + @property + def options(self): + # TODO: Well, crap. + # + # So this is the bit of the code that is the most likely to cause us + # trouble. Essentially we need to enumerate all of the SSL options that + # users might want to use and try to see if we can sensibly translate + # them, or whether we should just ignore them. + return self._options + + @options.setter + def options(self, value): + # TODO: Update in line with above. + self._options = value + + @property + def verify_mode(self): + return ssl.CERT_REQUIRED if self._verify else ssl.CERT_NONE + + @verify_mode.setter + def verify_mode(self, value): + self._verify = True if value == ssl.CERT_REQUIRED else False + + def set_default_verify_paths(self): + # So, this has to do something a bit weird. Specifically, what it does + # is nothing. + # + # This means that, if we had previously had load_verify_locations + # called, this does not undo that. We need to do that because it turns + # out that the rest of the urllib3 code will attempt to load the + # default verify paths if it hasn't been told about any paths, even if + # the context itself was sometime earlier. We resolve that by just + # ignoring it. + pass + + def load_default_certs(self): + return self.set_default_verify_paths() + + def set_ciphers(self, ciphers): + # For now, we just require the default cipher string. + if ciphers != util.ssl_.DEFAULT_CIPHERS: + raise ValueError("SecureTransport doesn't support custom cipher strings") + + def load_verify_locations(self, cafile=None, capath=None, cadata=None): + # OK, we only really support cadata and cafile. + if capath is not None: + raise ValueError("SecureTransport does not support cert directories") + + # Raise if cafile does not exist. + if cafile is not None: + with open(cafile): + pass + + self._trust_bundle = cafile or cadata + + def load_cert_chain(self, certfile, keyfile=None, password=None): + self._client_cert = certfile + self._client_key = keyfile + self._client_cert_passphrase = password + + def set_alpn_protocols(self, protocols): + """ + Sets the ALPN protocols that will later be set on the context. + + Raises a NotImplementedError if ALPN is not supported. + """ + if not hasattr(Security, "SSLSetALPNProtocols"): + raise NotImplementedError( + "SecureTransport supports ALPN only in macOS 10.12+" + ) + self._alpn_protocols = [six.ensure_binary(p) for p in protocols] + + def wrap_socket( + self, + sock, + server_side=False, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + server_hostname=None, + ): + # So, what do we do here? Firstly, we assert some properties. This is a + # stripped down shim, so there is some functionality we don't support. + # See PEP 543 for the real deal. + assert not server_side + assert do_handshake_on_connect + assert suppress_ragged_eofs + + # Ok, we're good to go. Now we want to create the wrapped socket object + # and store it in the appropriate place. + wrapped_socket = WrappedSocket(sock) + + # Now we can handshake + wrapped_socket.handshake( + server_hostname, + self._verify, + self._trust_bundle, + self._min_version, + self._max_version, + self._client_cert, + self._client_key, + self._client_key_passphrase, + self._alpn_protocols, + ) + return wrapped_socket diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/socks.py b/openpype/hosts/fusion/vendor/urllib3/contrib/socks.py new file mode 100644 index 0000000000..c326e80dd1 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/socks.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +""" +This module contains provisional support for SOCKS proxies from within +urllib3. This module supports SOCKS4, SOCKS4A (an extension of SOCKS4), and +SOCKS5. To enable its functionality, either install PySocks or install this +module with the ``socks`` extra. + +The SOCKS implementation supports the full range of urllib3 features. It also +supports the following SOCKS features: + +- SOCKS4A (``proxy_url='socks4a://...``) +- SOCKS4 (``proxy_url='socks4://...``) +- SOCKS5 with remote DNS (``proxy_url='socks5h://...``) +- SOCKS5 with local DNS (``proxy_url='socks5://...``) +- Usernames and passwords for the SOCKS proxy + +.. note:: + It is recommended to use ``socks5h://`` or ``socks4a://`` schemes in + your ``proxy_url`` to ensure that DNS resolution is done from the remote + server instead of client-side when connecting to a domain name. + +SOCKS4 supports IPv4 and domain names with the SOCKS4A extension. SOCKS5 +supports IPv4, IPv6, and domain names. + +When connecting to a SOCKS4 proxy the ``username`` portion of the ``proxy_url`` +will be sent as the ``userid`` section of the SOCKS request: + +.. code-block:: python + + proxy_url="socks4a://@proxy-host" + +When connecting to a SOCKS5 proxy the ``username`` and ``password`` portion +of the ``proxy_url`` will be sent as the username/password to authenticate +with the proxy: + +.. code-block:: python + + proxy_url="socks5h://:@proxy-host" + +""" +from __future__ import absolute_import + +try: + import socks +except ImportError: + import warnings + + from ..exceptions import DependencyWarning + + warnings.warn( + ( + "SOCKS support in urllib3 requires the installation of optional " + "dependencies: specifically, PySocks. For more information, see " + "https://urllib3.readthedocs.io/en/1.26.x/contrib.html#socks-proxies" + ), + DependencyWarning, + ) + raise + +from socket import error as SocketError +from socket import timeout as SocketTimeout + +from ..connection import HTTPConnection, HTTPSConnection +from ..connectionpool import HTTPConnectionPool, HTTPSConnectionPool +from ..exceptions import ConnectTimeoutError, NewConnectionError +from ..poolmanager import PoolManager +from ..util.url import parse_url + +try: + import ssl +except ImportError: + ssl = None + + +class SOCKSConnection(HTTPConnection): + """ + A plain-text HTTP connection that connects via a SOCKS proxy. + """ + + def __init__(self, *args, **kwargs): + self._socks_options = kwargs.pop("_socks_options") + super(SOCKSConnection, self).__init__(*args, **kwargs) + + def _new_conn(self): + """ + Establish a new connection via the SOCKS proxy. + """ + extra_kw = {} + if self.source_address: + extra_kw["source_address"] = self.source_address + + if self.socket_options: + extra_kw["socket_options"] = self.socket_options + + try: + conn = socks.create_connection( + (self.host, self.port), + proxy_type=self._socks_options["socks_version"], + proxy_addr=self._socks_options["proxy_host"], + proxy_port=self._socks_options["proxy_port"], + proxy_username=self._socks_options["username"], + proxy_password=self._socks_options["password"], + proxy_rdns=self._socks_options["rdns"], + timeout=self.timeout, + **extra_kw + ) + + except SocketTimeout: + raise ConnectTimeoutError( + self, + "Connection to %s timed out. (connect timeout=%s)" + % (self.host, self.timeout), + ) + + except socks.ProxyError as e: + # This is fragile as hell, but it seems to be the only way to raise + # useful errors here. + if e.socket_err: + error = e.socket_err + if isinstance(error, SocketTimeout): + raise ConnectTimeoutError( + self, + "Connection to %s timed out. (connect timeout=%s)" + % (self.host, self.timeout), + ) + else: + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % error + ) + else: + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % e + ) + + except SocketError as e: # Defensive: PySocks should catch all these. + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % e + ) + + return conn + + +# We don't need to duplicate the Verified/Unverified distinction from +# urllib3/connection.py here because the HTTPSConnection will already have been +# correctly set to either the Verified or Unverified form by that module. This +# means the SOCKSHTTPSConnection will automatically be the correct type. +class SOCKSHTTPSConnection(SOCKSConnection, HTTPSConnection): + pass + + +class SOCKSHTTPConnectionPool(HTTPConnectionPool): + ConnectionCls = SOCKSConnection + + +class SOCKSHTTPSConnectionPool(HTTPSConnectionPool): + ConnectionCls = SOCKSHTTPSConnection + + +class SOCKSProxyManager(PoolManager): + """ + A version of the urllib3 ProxyManager that routes connections via the + defined SOCKS proxy. + """ + + pool_classes_by_scheme = { + "http": SOCKSHTTPConnectionPool, + "https": SOCKSHTTPSConnectionPool, + } + + def __init__( + self, + proxy_url, + username=None, + password=None, + num_pools=10, + headers=None, + **connection_pool_kw + ): + parsed = parse_url(proxy_url) + + if username is None and password is None and parsed.auth is not None: + split = parsed.auth.split(":") + if len(split) == 2: + username, password = split + if parsed.scheme == "socks5": + socks_version = socks.PROXY_TYPE_SOCKS5 + rdns = False + elif parsed.scheme == "socks5h": + socks_version = socks.PROXY_TYPE_SOCKS5 + rdns = True + elif parsed.scheme == "socks4": + socks_version = socks.PROXY_TYPE_SOCKS4 + rdns = False + elif parsed.scheme == "socks4a": + socks_version = socks.PROXY_TYPE_SOCKS4 + rdns = True + else: + raise ValueError("Unable to determine SOCKS version from %s" % proxy_url) + + self.proxy_url = proxy_url + + socks_options = { + "socks_version": socks_version, + "proxy_host": parsed.host, + "proxy_port": parsed.port, + "username": username, + "password": password, + "rdns": rdns, + } + connection_pool_kw["_socks_options"] = socks_options + + super(SOCKSProxyManager, self).__init__( + num_pools, headers, **connection_pool_kw + ) + + self.pool_classes_by_scheme = SOCKSProxyManager.pool_classes_by_scheme diff --git a/openpype/hosts/fusion/vendor/urllib3/exceptions.py b/openpype/hosts/fusion/vendor/urllib3/exceptions.py new file mode 100644 index 0000000000..cba6f3f560 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/exceptions.py @@ -0,0 +1,323 @@ +from __future__ import absolute_import + +from .packages.six.moves.http_client import IncompleteRead as httplib_IncompleteRead + +# Base Exceptions + + +class HTTPError(Exception): + """Base exception used by this module.""" + + pass + + +class HTTPWarning(Warning): + """Base warning used by this module.""" + + pass + + +class PoolError(HTTPError): + """Base exception for errors caused within a pool.""" + + def __init__(self, pool, message): + self.pool = pool + HTTPError.__init__(self, "%s: %s" % (pool, message)) + + def __reduce__(self): + # For pickling purposes. + return self.__class__, (None, None) + + +class RequestError(PoolError): + """Base exception for PoolErrors that have associated URLs.""" + + def __init__(self, pool, url, message): + self.url = url + PoolError.__init__(self, pool, message) + + def __reduce__(self): + # For pickling purposes. + return self.__class__, (None, self.url, None) + + +class SSLError(HTTPError): + """Raised when SSL certificate fails in an HTTPS connection.""" + + pass + + +class ProxyError(HTTPError): + """Raised when the connection to a proxy fails.""" + + def __init__(self, message, error, *args): + super(ProxyError, self).__init__(message, error, *args) + self.original_error = error + + +class DecodeError(HTTPError): + """Raised when automatic decoding based on Content-Type fails.""" + + pass + + +class ProtocolError(HTTPError): + """Raised when something unexpected happens mid-request/response.""" + + pass + + +#: Renamed to ProtocolError but aliased for backwards compatibility. +ConnectionError = ProtocolError + + +# Leaf Exceptions + + +class MaxRetryError(RequestError): + """Raised when the maximum number of retries is exceeded. + + :param pool: The connection pool + :type pool: :class:`~urllib3.connectionpool.HTTPConnectionPool` + :param string url: The requested Url + :param exceptions.Exception reason: The underlying error + + """ + + def __init__(self, pool, url, reason=None): + self.reason = reason + + message = "Max retries exceeded with url: %s (Caused by %r)" % (url, reason) + + RequestError.__init__(self, pool, url, message) + + +class HostChangedError(RequestError): + """Raised when an existing pool gets a request for a foreign host.""" + + def __init__(self, pool, url, retries=3): + message = "Tried to open a foreign host with url: %s" % url + RequestError.__init__(self, pool, url, message) + self.retries = retries + + +class TimeoutStateError(HTTPError): + """Raised when passing an invalid state to a timeout""" + + pass + + +class TimeoutError(HTTPError): + """Raised when a socket timeout error occurs. + + Catching this error will catch both :exc:`ReadTimeoutErrors + ` and :exc:`ConnectTimeoutErrors `. + """ + + pass + + +class ReadTimeoutError(TimeoutError, RequestError): + """Raised when a socket timeout occurs while receiving data from a server""" + + pass + + +# This timeout error does not have a URL attached and needs to inherit from the +# base HTTPError +class ConnectTimeoutError(TimeoutError): + """Raised when a socket timeout occurs while connecting to a server""" + + pass + + +class NewConnectionError(ConnectTimeoutError, PoolError): + """Raised when we fail to establish a new connection. Usually ECONNREFUSED.""" + + pass + + +class EmptyPoolError(PoolError): + """Raised when a pool runs out of connections and no more are allowed.""" + + pass + + +class ClosedPoolError(PoolError): + """Raised when a request enters a pool after the pool has been closed.""" + + pass + + +class LocationValueError(ValueError, HTTPError): + """Raised when there is something wrong with a given URL input.""" + + pass + + +class LocationParseError(LocationValueError): + """Raised when get_host or similar fails to parse the URL input.""" + + def __init__(self, location): + message = "Failed to parse: %s" % location + HTTPError.__init__(self, message) + + self.location = location + + +class URLSchemeUnknown(LocationValueError): + """Raised when a URL input has an unsupported scheme.""" + + def __init__(self, scheme): + message = "Not supported URL scheme %s" % scheme + super(URLSchemeUnknown, self).__init__(message) + + self.scheme = scheme + + +class ResponseError(HTTPError): + """Used as a container for an error reason supplied in a MaxRetryError.""" + + GENERIC_ERROR = "too many error responses" + SPECIFIC_ERROR = "too many {status_code} error responses" + + +class SecurityWarning(HTTPWarning): + """Warned when performing security reducing actions""" + + pass + + +class SubjectAltNameWarning(SecurityWarning): + """Warned when connecting to a host with a certificate missing a SAN.""" + + pass + + +class InsecureRequestWarning(SecurityWarning): + """Warned when making an unverified HTTPS request.""" + + pass + + +class SystemTimeWarning(SecurityWarning): + """Warned when system time is suspected to be wrong""" + + pass + + +class InsecurePlatformWarning(SecurityWarning): + """Warned when certain TLS/SSL configuration is not available on a platform.""" + + pass + + +class SNIMissingWarning(HTTPWarning): + """Warned when making a HTTPS request without SNI available.""" + + pass + + +class DependencyWarning(HTTPWarning): + """ + Warned when an attempt is made to import a module with missing optional + dependencies. + """ + + pass + + +class ResponseNotChunked(ProtocolError, ValueError): + """Response needs to be chunked in order to read it as chunks.""" + + pass + + +class BodyNotHttplibCompatible(HTTPError): + """ + Body should be :class:`http.client.HTTPResponse` like + (have an fp attribute which returns raw chunks) for read_chunked(). + """ + + pass + + +class IncompleteRead(HTTPError, httplib_IncompleteRead): + """ + Response length doesn't match expected Content-Length + + Subclass of :class:`http.client.IncompleteRead` to allow int value + for ``partial`` to avoid creating large objects on streamed reads. + """ + + def __init__(self, partial, expected): + super(IncompleteRead, self).__init__(partial, expected) + + def __repr__(self): + return "IncompleteRead(%i bytes read, %i more expected)" % ( + self.partial, + self.expected, + ) + + +class InvalidChunkLength(HTTPError, httplib_IncompleteRead): + """Invalid chunk length in a chunked response.""" + + def __init__(self, response, length): + super(InvalidChunkLength, self).__init__( + response.tell(), response.length_remaining + ) + self.response = response + self.length = length + + def __repr__(self): + return "InvalidChunkLength(got length %r, %i bytes read)" % ( + self.length, + self.partial, + ) + + +class InvalidHeader(HTTPError): + """The header provided was somehow invalid.""" + + pass + + +class ProxySchemeUnknown(AssertionError, URLSchemeUnknown): + """ProxyManager does not support the supplied scheme""" + + # TODO(t-8ch): Stop inheriting from AssertionError in v2.0. + + def __init__(self, scheme): + # 'localhost' is here because our URL parser parses + # localhost:8080 -> scheme=localhost, remove if we fix this. + if scheme == "localhost": + scheme = None + if scheme is None: + message = "Proxy URL had no scheme, should start with http:// or https://" + else: + message = ( + "Proxy URL had unsupported scheme %s, should use http:// or https://" + % scheme + ) + super(ProxySchemeUnknown, self).__init__(message) + + +class ProxySchemeUnsupported(ValueError): + """Fetching HTTPS resources through HTTPS proxies is unsupported""" + + pass + + +class HeaderParsingError(HTTPError): + """Raised by assert_header_parsing, but we convert it to a log.warning statement.""" + + def __init__(self, defects, unparsed_data): + message = "%s, unparsed data: %r" % (defects or "Unknown", unparsed_data) + super(HeaderParsingError, self).__init__(message) + + +class UnrewindableBodyError(HTTPError): + """urllib3 encountered an error when trying to rewind a body""" + + pass diff --git a/openpype/hosts/fusion/vendor/urllib3/fields.py b/openpype/hosts/fusion/vendor/urllib3/fields.py new file mode 100644 index 0000000000..9d630f491d --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/fields.py @@ -0,0 +1,274 @@ +from __future__ import absolute_import + +import email.utils +import mimetypes +import re + +from .packages import six + + +def guess_content_type(filename, default="application/octet-stream"): + """ + Guess the "Content-Type" of a file. + + :param filename: + The filename to guess the "Content-Type" of using :mod:`mimetypes`. + :param default: + If no "Content-Type" can be guessed, default to `default`. + """ + if filename: + return mimetypes.guess_type(filename)[0] or default + return default + + +def format_header_param_rfc2231(name, value): + """ + Helper function to format and quote a single header parameter using the + strategy defined in RFC 2231. + + Particularly useful for header parameters which might contain + non-ASCII values, like file names. This follows + `RFC 2388 Section 4.4 `_. + + :param name: + The name of the parameter, a string expected to be ASCII only. + :param value: + The value of the parameter, provided as ``bytes`` or `str``. + :ret: + An RFC-2231-formatted unicode string. + """ + if isinstance(value, six.binary_type): + value = value.decode("utf-8") + + if not any(ch in value for ch in '"\\\r\n'): + result = u'%s="%s"' % (name, value) + try: + result.encode("ascii") + except (UnicodeEncodeError, UnicodeDecodeError): + pass + else: + return result + + if six.PY2: # Python 2: + value = value.encode("utf-8") + + # encode_rfc2231 accepts an encoded string and returns an ascii-encoded + # string in Python 2 but accepts and returns unicode strings in Python 3 + value = email.utils.encode_rfc2231(value, "utf-8") + value = "%s*=%s" % (name, value) + + if six.PY2: # Python 2: + value = value.decode("utf-8") + + return value + + +_HTML5_REPLACEMENTS = { + u"\u0022": u"%22", + # Replace "\" with "\\". + u"\u005C": u"\u005C\u005C", +} + +# All control characters from 0x00 to 0x1F *except* 0x1B. +_HTML5_REPLACEMENTS.update( + { + six.unichr(cc): u"%{:02X}".format(cc) + for cc in range(0x00, 0x1F + 1) + if cc not in (0x1B,) + } +) + + +def _replace_multiple(value, needles_and_replacements): + def replacer(match): + return needles_and_replacements[match.group(0)] + + pattern = re.compile( + r"|".join([re.escape(needle) for needle in needles_and_replacements.keys()]) + ) + + result = pattern.sub(replacer, value) + + return result + + +def format_header_param_html5(name, value): + """ + Helper function to format and quote a single header parameter using the + HTML5 strategy. + + Particularly useful for header parameters which might contain + non-ASCII values, like file names. This follows the `HTML5 Working Draft + Section 4.10.22.7`_ and matches the behavior of curl and modern browsers. + + .. _HTML5 Working Draft Section 4.10.22.7: + https://w3c.github.io/html/sec-forms.html#multipart-form-data + + :param name: + The name of the parameter, a string expected to be ASCII only. + :param value: + The value of the parameter, provided as ``bytes`` or `str``. + :ret: + A unicode string, stripped of troublesome characters. + """ + if isinstance(value, six.binary_type): + value = value.decode("utf-8") + + value = _replace_multiple(value, _HTML5_REPLACEMENTS) + + return u'%s="%s"' % (name, value) + + +# For backwards-compatibility. +format_header_param = format_header_param_html5 + + +class RequestField(object): + """ + A data container for request body parameters. + + :param name: + The name of this request field. Must be unicode. + :param data: + The data/value body. + :param filename: + An optional filename of the request field. Must be unicode. + :param headers: + An optional dict-like object of headers to initially use for the field. + :param header_formatter: + An optional callable that is used to encode and format the headers. By + default, this is :func:`format_header_param_html5`. + """ + + def __init__( + self, + name, + data, + filename=None, + headers=None, + header_formatter=format_header_param_html5, + ): + self._name = name + self._filename = filename + self.data = data + self.headers = {} + if headers: + self.headers = dict(headers) + self.header_formatter = header_formatter + + @classmethod + def from_tuples(cls, fieldname, value, header_formatter=format_header_param_html5): + """ + A :class:`~urllib3.fields.RequestField` factory from old-style tuple parameters. + + Supports constructing :class:`~urllib3.fields.RequestField` from + parameter of key/value strings AND key/filetuple. A filetuple is a + (filename, data, MIME type) tuple where the MIME type is optional. + For example:: + + 'foo': 'bar', + 'fakefile': ('foofile.txt', 'contents of foofile'), + 'realfile': ('barfile.txt', open('realfile').read()), + 'typedfile': ('bazfile.bin', open('bazfile').read(), 'image/jpeg'), + 'nonamefile': 'contents of nonamefile field', + + Field names and filenames must be unicode. + """ + if isinstance(value, tuple): + if len(value) == 3: + filename, data, content_type = value + else: + filename, data = value + content_type = guess_content_type(filename) + else: + filename = None + content_type = None + data = value + + request_param = cls( + fieldname, data, filename=filename, header_formatter=header_formatter + ) + request_param.make_multipart(content_type=content_type) + + return request_param + + def _render_part(self, name, value): + """ + Overridable helper function to format a single header parameter. By + default, this calls ``self.header_formatter``. + + :param name: + The name of the parameter, a string expected to be ASCII only. + :param value: + The value of the parameter, provided as a unicode string. + """ + + return self.header_formatter(name, value) + + def _render_parts(self, header_parts): + """ + Helper function to format and quote a single header. + + Useful for single headers that are composed of multiple items. E.g., + 'Content-Disposition' fields. + + :param header_parts: + A sequence of (k, v) tuples or a :class:`dict` of (k, v) to format + as `k1="v1"; k2="v2"; ...`. + """ + parts = [] + iterable = header_parts + if isinstance(header_parts, dict): + iterable = header_parts.items() + + for name, value in iterable: + if value is not None: + parts.append(self._render_part(name, value)) + + return u"; ".join(parts) + + def render_headers(self): + """ + Renders the headers for this request field. + """ + lines = [] + + sort_keys = ["Content-Disposition", "Content-Type", "Content-Location"] + for sort_key in sort_keys: + if self.headers.get(sort_key, False): + lines.append(u"%s: %s" % (sort_key, self.headers[sort_key])) + + for header_name, header_value in self.headers.items(): + if header_name not in sort_keys: + if header_value: + lines.append(u"%s: %s" % (header_name, header_value)) + + lines.append(u"\r\n") + return u"\r\n".join(lines) + + def make_multipart( + self, content_disposition=None, content_type=None, content_location=None + ): + """ + Makes this request field into a multipart request field. + + This method overrides "Content-Disposition", "Content-Type" and + "Content-Location" headers to the request parameter. + + :param content_type: + The 'Content-Type' of the request body. + :param content_location: + The 'Content-Location' of the request body. + + """ + self.headers["Content-Disposition"] = content_disposition or u"form-data" + self.headers["Content-Disposition"] += u"; ".join( + [ + u"", + self._render_parts( + ((u"name", self._name), (u"filename", self._filename)) + ), + ] + ) + self.headers["Content-Type"] = content_type + self.headers["Content-Location"] = content_location diff --git a/openpype/hosts/fusion/vendor/urllib3/filepost.py b/openpype/hosts/fusion/vendor/urllib3/filepost.py new file mode 100644 index 0000000000..36c9252c64 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/filepost.py @@ -0,0 +1,98 @@ +from __future__ import absolute_import + +import binascii +import codecs +import os +from io import BytesIO + +from .fields import RequestField +from .packages import six +from .packages.six import b + +writer = codecs.lookup("utf-8")[3] + + +def choose_boundary(): + """ + Our embarrassingly-simple replacement for mimetools.choose_boundary. + """ + boundary = binascii.hexlify(os.urandom(16)) + if not six.PY2: + boundary = boundary.decode("ascii") + return boundary + + +def iter_field_objects(fields): + """ + Iterate over fields. + + Supports list of (k, v) tuples and dicts, and lists of + :class:`~urllib3.fields.RequestField`. + + """ + if isinstance(fields, dict): + i = six.iteritems(fields) + else: + i = iter(fields) + + for field in i: + if isinstance(field, RequestField): + yield field + else: + yield RequestField.from_tuples(*field) + + +def iter_fields(fields): + """ + .. deprecated:: 1.6 + + Iterate over fields. + + The addition of :class:`~urllib3.fields.RequestField` makes this function + obsolete. Instead, use :func:`iter_field_objects`, which returns + :class:`~urllib3.fields.RequestField` objects. + + Supports list of (k, v) tuples and dicts. + """ + if isinstance(fields, dict): + return ((k, v) for k, v in six.iteritems(fields)) + + return ((k, v) for k, v in fields) + + +def encode_multipart_formdata(fields, boundary=None): + """ + Encode a dictionary of ``fields`` using the multipart/form-data MIME format. + + :param fields: + Dictionary of fields or list of (key, :class:`~urllib3.fields.RequestField`). + + :param boundary: + If not specified, then a random boundary will be generated using + :func:`urllib3.filepost.choose_boundary`. + """ + body = BytesIO() + if boundary is None: + boundary = choose_boundary() + + for field in iter_field_objects(fields): + body.write(b("--%s\r\n" % (boundary))) + + writer(body).write(field.render_headers()) + data = field.data + + if isinstance(data, int): + data = str(data) # Backwards compatibility + + if isinstance(data, six.text_type): + writer(body).write(data) + else: + body.write(data) + + body.write(b"\r\n") + + body.write(b("--%s--\r\n" % (boundary))) + + content_type = str("multipart/form-data; boundary=%s" % boundary) + + return body.getvalue(), content_type diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/__init__.py b/openpype/hosts/fusion/vendor/urllib3/packages/__init__.py new file mode 100644 index 0000000000..fce4caa65d --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import + +from . import ssl_match_hostname + +__all__ = ("ssl_match_hostname",) diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/backports/__init__.py b/openpype/hosts/fusion/vendor/urllib3/packages/backports/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py b/openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py new file mode 100644 index 0000000000..b8fb2154b6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +backports.makefile +~~~~~~~~~~~~~~~~~~ + +Backports the Python 3 ``socket.makefile`` method for use with anything that +wants to create a "fake" socket object. +""" +import io +from socket import SocketIO + + +def backport_makefile( + self, mode="r", buffering=None, encoding=None, errors=None, newline=None +): + """ + Backport of ``socket.makefile`` from Python 3.5. + """ + if not set(mode) <= {"r", "w", "b"}: + raise ValueError("invalid mode %r (only r, w, b allowed)" % (mode,)) + writing = "w" in mode + reading = "r" in mode or not writing + assert reading or writing + binary = "b" in mode + rawmode = "" + if reading: + rawmode += "r" + if writing: + rawmode += "w" + raw = SocketIO(self, rawmode) + self._makefile_refs += 1 + if buffering is None: + buffering = -1 + if buffering < 0: + buffering = io.DEFAULT_BUFFER_SIZE + if buffering == 0: + if not binary: + raise ValueError("unbuffered streams must be binary") + return raw + if reading and writing: + buffer = io.BufferedRWPair(raw, raw, buffering) + elif reading: + buffer = io.BufferedReader(raw, buffering) + else: + assert writing + buffer = io.BufferedWriter(raw, buffering) + if binary: + return buffer + text = io.TextIOWrapper(buffer, encoding, errors, newline) + text.mode = mode + return text diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/six.py b/openpype/hosts/fusion/vendor/urllib3/packages/six.py new file mode 100644 index 0000000000..ba50acb062 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/six.py @@ -0,0 +1,1077 @@ +# Copyright (c) 2010-2020 Benjamin Peterson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Utilities for writing code that runs on Python 2 and 3""" + +from __future__ import absolute_import + +import functools +import itertools +import operator +import sys +import types + +__author__ = "Benjamin Peterson " +__version__ = "1.16.0" + + +# Useful for very coarse version differentiation. +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 +PY34 = sys.version_info[0:2] >= (3, 4) + +if PY3: + string_types = (str,) + integer_types = (int,) + class_types = (type,) + text_type = str + binary_type = bytes + + MAXSIZE = sys.maxsize +else: + string_types = (basestring,) + integer_types = (int, long) + class_types = (type, types.ClassType) + text_type = unicode + binary_type = str + + if sys.platform.startswith("java"): + # Jython always uses 32 bits. + MAXSIZE = int((1 << 31) - 1) + else: + # It's possible to have sizeof(long) != sizeof(Py_ssize_t). + class X(object): + def __len__(self): + return 1 << 31 + + try: + len(X()) + except OverflowError: + # 32-bit + MAXSIZE = int((1 << 31) - 1) + else: + # 64-bit + MAXSIZE = int((1 << 63) - 1) + del X + +if PY34: + from importlib.util import spec_from_loader +else: + spec_from_loader = None + + +def _add_doc(func, doc): + """Add documentation to a function.""" + func.__doc__ = doc + + +def _import_module(name): + """Import module, returning the module after the last dot.""" + __import__(name) + return sys.modules[name] + + +class _LazyDescr(object): + def __init__(self, name): + self.name = name + + def __get__(self, obj, tp): + result = self._resolve() + setattr(obj, self.name, result) # Invokes __set__. + try: + # This is a bit ugly, but it avoids running this again by + # removing this descriptor. + delattr(obj.__class__, self.name) + except AttributeError: + pass + return result + + +class MovedModule(_LazyDescr): + def __init__(self, name, old, new=None): + super(MovedModule, self).__init__(name) + if PY3: + if new is None: + new = name + self.mod = new + else: + self.mod = old + + def _resolve(self): + return _import_module(self.mod) + + def __getattr__(self, attr): + _module = self._resolve() + value = getattr(_module, attr) + setattr(self, attr, value) + return value + + +class _LazyModule(types.ModuleType): + def __init__(self, name): + super(_LazyModule, self).__init__(name) + self.__doc__ = self.__class__.__doc__ + + def __dir__(self): + attrs = ["__doc__", "__name__"] + attrs += [attr.name for attr in self._moved_attributes] + return attrs + + # Subclasses should override this + _moved_attributes = [] + + +class MovedAttribute(_LazyDescr): + def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): + super(MovedAttribute, self).__init__(name) + if PY3: + if new_mod is None: + new_mod = name + self.mod = new_mod + if new_attr is None: + if old_attr is None: + new_attr = name + else: + new_attr = old_attr + self.attr = new_attr + else: + self.mod = old_mod + if old_attr is None: + old_attr = name + self.attr = old_attr + + def _resolve(self): + module = _import_module(self.mod) + return getattr(module, self.attr) + + +class _SixMetaPathImporter(object): + + """ + A meta path importer to import six.moves and its submodules. + + This class implements a PEP302 finder and loader. It should be compatible + with Python 2.5 and all existing versions of Python3 + """ + + def __init__(self, six_module_name): + self.name = six_module_name + self.known_modules = {} + + def _add_module(self, mod, *fullnames): + for fullname in fullnames: + self.known_modules[self.name + "." + fullname] = mod + + def _get_module(self, fullname): + return self.known_modules[self.name + "." + fullname] + + def find_module(self, fullname, path=None): + if fullname in self.known_modules: + return self + return None + + def find_spec(self, fullname, path, target=None): + if fullname in self.known_modules: + return spec_from_loader(fullname, self) + return None + + def __get_module(self, fullname): + try: + return self.known_modules[fullname] + except KeyError: + raise ImportError("This loader does not know module " + fullname) + + def load_module(self, fullname): + try: + # in case of a reload + return sys.modules[fullname] + except KeyError: + pass + mod = self.__get_module(fullname) + if isinstance(mod, MovedModule): + mod = mod._resolve() + else: + mod.__loader__ = self + sys.modules[fullname] = mod + return mod + + def is_package(self, fullname): + """ + Return true, if the named module is a package. + + We need this method to get correct spec objects with + Python 3.4 (see PEP451) + """ + return hasattr(self.__get_module(fullname), "__path__") + + def get_code(self, fullname): + """Return None + + Required, if is_package is implemented""" + self.__get_module(fullname) # eventually raises ImportError + return None + + get_source = get_code # same as get_code + + def create_module(self, spec): + return self.load_module(spec.name) + + def exec_module(self, module): + pass + + +_importer = _SixMetaPathImporter(__name__) + + +class _MovedItems(_LazyModule): + + """Lazy loading of moved objects""" + + __path__ = [] # mark as package + + +_moved_attributes = [ + MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), + MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), + MovedAttribute( + "filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse" + ), + MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), + MovedAttribute("intern", "__builtin__", "sys"), + MovedAttribute("map", "itertools", "builtins", "imap", "map"), + MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"), + MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"), + MovedAttribute("getoutput", "commands", "subprocess"), + MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute( + "reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload" + ), + MovedAttribute("reduce", "__builtin__", "functools"), + MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), + MovedAttribute("StringIO", "StringIO", "io"), + MovedAttribute("UserDict", "UserDict", "collections"), + MovedAttribute("UserList", "UserList", "collections"), + MovedAttribute("UserString", "UserString", "collections"), + MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), + MovedAttribute( + "zip_longest", "itertools", "itertools", "izip_longest", "zip_longest" + ), + MovedModule("builtins", "__builtin__"), + MovedModule("configparser", "ConfigParser"), + MovedModule( + "collections_abc", + "collections", + "collections.abc" if sys.version_info >= (3, 3) else "collections", + ), + MovedModule("copyreg", "copy_reg"), + MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), + MovedModule("dbm_ndbm", "dbm", "dbm.ndbm"), + MovedModule( + "_dummy_thread", + "dummy_thread", + "_dummy_thread" if sys.version_info < (3, 9) else "_thread", + ), + MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), + MovedModule("http_cookies", "Cookie", "http.cookies"), + MovedModule("html_entities", "htmlentitydefs", "html.entities"), + MovedModule("html_parser", "HTMLParser", "html.parser"), + MovedModule("http_client", "httplib", "http.client"), + MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), + MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"), + MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), + MovedModule( + "email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart" + ), + MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), + MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), + MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), + MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), + MovedModule("cPickle", "cPickle", "pickle"), + MovedModule("queue", "Queue"), + MovedModule("reprlib", "repr"), + MovedModule("socketserver", "SocketServer"), + MovedModule("_thread", "thread", "_thread"), + MovedModule("tkinter", "Tkinter"), + MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), + MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), + MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), + MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), + MovedModule("tkinter_tix", "Tix", "tkinter.tix"), + MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), + MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), + MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), + MovedModule("tkinter_colorchooser", "tkColorChooser", "tkinter.colorchooser"), + MovedModule("tkinter_commondialog", "tkCommonDialog", "tkinter.commondialog"), + MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), + MovedModule("tkinter_font", "tkFont", "tkinter.font"), + MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), + MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", "tkinter.simpledialog"), + MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), + MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), + MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), + MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), + MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), + MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), +] +# Add windows specific modules. +if sys.platform == "win32": + _moved_attributes += [ + MovedModule("winreg", "_winreg"), + ] + +for attr in _moved_attributes: + setattr(_MovedItems, attr.name, attr) + if isinstance(attr, MovedModule): + _importer._add_module(attr, "moves." + attr.name) +del attr + +_MovedItems._moved_attributes = _moved_attributes + +moves = _MovedItems(__name__ + ".moves") +_importer._add_module(moves, "moves") + + +class Module_six_moves_urllib_parse(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_parse""" + + +_urllib_parse_moved_attributes = [ + MovedAttribute("ParseResult", "urlparse", "urllib.parse"), + MovedAttribute("SplitResult", "urlparse", "urllib.parse"), + MovedAttribute("parse_qs", "urlparse", "urllib.parse"), + MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), + MovedAttribute("urldefrag", "urlparse", "urllib.parse"), + MovedAttribute("urljoin", "urlparse", "urllib.parse"), + MovedAttribute("urlparse", "urlparse", "urllib.parse"), + MovedAttribute("urlsplit", "urlparse", "urllib.parse"), + MovedAttribute("urlunparse", "urlparse", "urllib.parse"), + MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), + MovedAttribute("quote", "urllib", "urllib.parse"), + MovedAttribute("quote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote", "urllib", "urllib.parse"), + MovedAttribute("unquote_plus", "urllib", "urllib.parse"), + MovedAttribute( + "unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes" + ), + MovedAttribute("urlencode", "urllib", "urllib.parse"), + MovedAttribute("splitquery", "urllib", "urllib.parse"), + MovedAttribute("splittag", "urllib", "urllib.parse"), + MovedAttribute("splituser", "urllib", "urllib.parse"), + MovedAttribute("splitvalue", "urllib", "urllib.parse"), + MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), + MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), + MovedAttribute("uses_params", "urlparse", "urllib.parse"), + MovedAttribute("uses_query", "urlparse", "urllib.parse"), + MovedAttribute("uses_relative", "urlparse", "urllib.parse"), +] +for attr in _urllib_parse_moved_attributes: + setattr(Module_six_moves_urllib_parse, attr.name, attr) +del attr + +Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), + "moves.urllib_parse", + "moves.urllib.parse", +) + + +class Module_six_moves_urllib_error(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_error""" + + +_urllib_error_moved_attributes = [ + MovedAttribute("URLError", "urllib2", "urllib.error"), + MovedAttribute("HTTPError", "urllib2", "urllib.error"), + MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), +] +for attr in _urllib_error_moved_attributes: + setattr(Module_six_moves_urllib_error, attr.name, attr) +del attr + +Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), + "moves.urllib_error", + "moves.urllib.error", +) + + +class Module_six_moves_urllib_request(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_request""" + + +_urllib_request_moved_attributes = [ + MovedAttribute("urlopen", "urllib2", "urllib.request"), + MovedAttribute("install_opener", "urllib2", "urllib.request"), + MovedAttribute("build_opener", "urllib2", "urllib.request"), + MovedAttribute("pathname2url", "urllib", "urllib.request"), + MovedAttribute("url2pathname", "urllib", "urllib.request"), + MovedAttribute("getproxies", "urllib", "urllib.request"), + MovedAttribute("Request", "urllib2", "urllib.request"), + MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), + MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), + MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), + MovedAttribute("BaseHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), + MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), + MovedAttribute("FileHandler", "urllib2", "urllib.request"), + MovedAttribute("FTPHandler", "urllib2", "urllib.request"), + MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), + MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), + MovedAttribute("urlretrieve", "urllib", "urllib.request"), + MovedAttribute("urlcleanup", "urllib", "urllib.request"), + MovedAttribute("URLopener", "urllib", "urllib.request"), + MovedAttribute("FancyURLopener", "urllib", "urllib.request"), + MovedAttribute("proxy_bypass", "urllib", "urllib.request"), + MovedAttribute("parse_http_list", "urllib2", "urllib.request"), + MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"), +] +for attr in _urllib_request_moved_attributes: + setattr(Module_six_moves_urllib_request, attr.name, attr) +del attr + +Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), + "moves.urllib_request", + "moves.urllib.request", +) + + +class Module_six_moves_urllib_response(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_response""" + + +_urllib_response_moved_attributes = [ + MovedAttribute("addbase", "urllib", "urllib.response"), + MovedAttribute("addclosehook", "urllib", "urllib.response"), + MovedAttribute("addinfo", "urllib", "urllib.response"), + MovedAttribute("addinfourl", "urllib", "urllib.response"), +] +for attr in _urllib_response_moved_attributes: + setattr(Module_six_moves_urllib_response, attr.name, attr) +del attr + +Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), + "moves.urllib_response", + "moves.urllib.response", +) + + +class Module_six_moves_urllib_robotparser(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_robotparser""" + + +_urllib_robotparser_moved_attributes = [ + MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), +] +for attr in _urllib_robotparser_moved_attributes: + setattr(Module_six_moves_urllib_robotparser, attr.name, attr) +del attr + +Module_six_moves_urllib_robotparser._moved_attributes = ( + _urllib_robotparser_moved_attributes +) + +_importer._add_module( + Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), + "moves.urllib_robotparser", + "moves.urllib.robotparser", +) + + +class Module_six_moves_urllib(types.ModuleType): + + """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" + + __path__ = [] # mark as package + parse = _importer._get_module("moves.urllib_parse") + error = _importer._get_module("moves.urllib_error") + request = _importer._get_module("moves.urllib_request") + response = _importer._get_module("moves.urllib_response") + robotparser = _importer._get_module("moves.urllib_robotparser") + + def __dir__(self): + return ["parse", "error", "request", "response", "robotparser"] + + +_importer._add_module( + Module_six_moves_urllib(__name__ + ".moves.urllib"), "moves.urllib" +) + + +def add_move(move): + """Add an item to six.moves.""" + setattr(_MovedItems, move.name, move) + + +def remove_move(name): + """Remove item from six.moves.""" + try: + delattr(_MovedItems, name) + except AttributeError: + try: + del moves.__dict__[name] + except KeyError: + raise AttributeError("no such move, %r" % (name,)) + + +if PY3: + _meth_func = "__func__" + _meth_self = "__self__" + + _func_closure = "__closure__" + _func_code = "__code__" + _func_defaults = "__defaults__" + _func_globals = "__globals__" +else: + _meth_func = "im_func" + _meth_self = "im_self" + + _func_closure = "func_closure" + _func_code = "func_code" + _func_defaults = "func_defaults" + _func_globals = "func_globals" + + +try: + advance_iterator = next +except NameError: + + def advance_iterator(it): + return it.next() + + +next = advance_iterator + + +try: + callable = callable +except NameError: + + def callable(obj): + return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) + + +if PY3: + + def get_unbound_function(unbound): + return unbound + + create_bound_method = types.MethodType + + def create_unbound_method(func, cls): + return func + + Iterator = object +else: + + def get_unbound_function(unbound): + return unbound.im_func + + def create_bound_method(func, obj): + return types.MethodType(func, obj, obj.__class__) + + def create_unbound_method(func, cls): + return types.MethodType(func, None, cls) + + class Iterator(object): + def next(self): + return type(self).__next__(self) + + callable = callable +_add_doc( + get_unbound_function, """Get the function out of a possibly unbound function""" +) + + +get_method_function = operator.attrgetter(_meth_func) +get_method_self = operator.attrgetter(_meth_self) +get_function_closure = operator.attrgetter(_func_closure) +get_function_code = operator.attrgetter(_func_code) +get_function_defaults = operator.attrgetter(_func_defaults) +get_function_globals = operator.attrgetter(_func_globals) + + +if PY3: + + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") +else: + + def iterkeys(d, **kw): + return d.iterkeys(**kw) + + def itervalues(d, **kw): + return d.itervalues(**kw) + + def iteritems(d, **kw): + return d.iteritems(**kw) + + def iterlists(d, **kw): + return d.iterlists(**kw) + + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + +_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") +_add_doc(itervalues, "Return an iterator over the values of a dictionary.") +_add_doc(iteritems, "Return an iterator over the (key, value) pairs of a dictionary.") +_add_doc( + iterlists, "Return an iterator over the (key, [values]) pairs of a dictionary." +) + + +if PY3: + + def b(s): + return s.encode("latin-1") + + def u(s): + return s + + unichr = chr + import struct + + int2byte = struct.Struct(">B").pack + del struct + byte2int = operator.itemgetter(0) + indexbytes = operator.getitem + iterbytes = iter + import io + + StringIO = io.StringIO + BytesIO = io.BytesIO + del io + _assertCountEqual = "assertCountEqual" + if sys.version_info[1] <= 1: + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" + else: + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" + _assertNotRegex = "assertNotRegex" +else: + + def b(s): + return s + + # Workaround for standalone backslash + + def u(s): + return unicode(s.replace(r"\\", r"\\\\"), "unicode_escape") + + unichr = unichr + int2byte = chr + + def byte2int(bs): + return ord(bs[0]) + + def indexbytes(buf, i): + return ord(buf[i]) + + iterbytes = functools.partial(itertools.imap, ord) + import StringIO + + StringIO = BytesIO = StringIO.StringIO + _assertCountEqual = "assertItemsEqual" + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" +_add_doc(b, """Byte literal""") +_add_doc(u, """Text literal""") + + +def assertCountEqual(self, *args, **kwargs): + return getattr(self, _assertCountEqual)(*args, **kwargs) + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + + +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) + + +def assertNotRegex(self, *args, **kwargs): + return getattr(self, _assertNotRegex)(*args, **kwargs) + + +if PY3: + exec_ = getattr(moves.builtins, "exec") + + def reraise(tp, value, tb=None): + try: + if value is None: + value = tp() + if value.__traceback__ is not tb: + raise value.with_traceback(tb) + raise value + finally: + value = None + tb = None + + +else: + + def exec_(_code_, _globs_=None, _locs_=None): + """Execute code in a namespace.""" + if _globs_ is None: + frame = sys._getframe(1) + _globs_ = frame.f_globals + if _locs_ is None: + _locs_ = frame.f_locals + del frame + elif _locs_ is None: + _locs_ = _globs_ + exec ("""exec _code_ in _globs_, _locs_""") + + exec_( + """def reraise(tp, value, tb=None): + try: + raise tp, value, tb + finally: + tb = None +""" + ) + + +if sys.version_info[:2] > (3,): + exec_( + """def raise_from(value, from_value): + try: + raise value from from_value + finally: + value = None +""" + ) +else: + + def raise_from(value, from_value): + raise value + + +print_ = getattr(moves.builtins, "print", None) +if print_ is None: + + def print_(*args, **kwargs): + """The new-style print function for Python 2.4 and 2.5.""" + fp = kwargs.pop("file", sys.stdout) + if fp is None: + return + + def write(data): + if not isinstance(data, basestring): + data = str(data) + # If the file has an encoding, encode unicode with it. + if ( + isinstance(fp, file) + and isinstance(data, unicode) + and fp.encoding is not None + ): + errors = getattr(fp, "errors", None) + if errors is None: + errors = "strict" + data = data.encode(fp.encoding, errors) + fp.write(data) + + want_unicode = False + sep = kwargs.pop("sep", None) + if sep is not None: + if isinstance(sep, unicode): + want_unicode = True + elif not isinstance(sep, str): + raise TypeError("sep must be None or a string") + end = kwargs.pop("end", None) + if end is not None: + if isinstance(end, unicode): + want_unicode = True + elif not isinstance(end, str): + raise TypeError("end must be None or a string") + if kwargs: + raise TypeError("invalid keyword arguments to print()") + if not want_unicode: + for arg in args: + if isinstance(arg, unicode): + want_unicode = True + break + if want_unicode: + newline = unicode("\n") + space = unicode(" ") + else: + newline = "\n" + space = " " + if sep is None: + sep = space + if end is None: + end = newline + for i, arg in enumerate(args): + if i: + write(sep) + write(arg) + write(end) + + +if sys.version_info[:2] < (3, 3): + _print = print_ + + def print_(*args, **kwargs): + fp = kwargs.get("file", sys.stdout) + flush = kwargs.pop("flush", False) + _print(*args, **kwargs) + if flush and fp is not None: + fp.flush() + + +_add_doc(reraise, """Reraise an exception.""") + +if sys.version_info[0:2] < (3, 4): + # This does exactly the same what the :func:`py3:functools.update_wrapper` + # function does on Python versions after 3.2. It sets the ``__wrapped__`` + # attribute on ``wrapper`` object and it doesn't raise an error if any of + # the attributes mentioned in ``assigned`` and ``updated`` are missing on + # ``wrapped`` object. + def _update_wrapper( + wrapper, + wrapped, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES, + ): + for attr in assigned: + try: + value = getattr(wrapped, attr) + except AttributeError: + continue + else: + setattr(wrapper, attr, value) + for attr in updated: + getattr(wrapper, attr).update(getattr(wrapped, attr, {})) + wrapper.__wrapped__ = wrapped + return wrapper + + _update_wrapper.__doc__ = functools.update_wrapper.__doc__ + + def wraps( + wrapped, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES, + ): + return functools.partial( + _update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated + ) + + wraps.__doc__ = functools.wraps.__doc__ + +else: + wraps = functools.wraps + + +def with_metaclass(meta, *bases): + """Create a base class with a metaclass.""" + # This requires a bit of explanation: the basic idea is to make a dummy + # metaclass for one level of class instantiation that replaces itself with + # the actual metaclass. + class metaclass(type): + def __new__(cls, name, this_bases, d): + if sys.version_info[:2] >= (3, 7): + # This version introduced PEP 560 that requires a bit + # of extra care (we mimic what is done by __build_class__). + resolved_bases = types.resolve_bases(bases) + if resolved_bases is not bases: + d["__orig_bases__"] = bases + else: + resolved_bases = bases + return meta(name, resolved_bases, d) + + @classmethod + def __prepare__(cls, name, this_bases): + return meta.__prepare__(name, bases) + + return type.__new__(metaclass, "temporary_class", (), {}) + + +def add_metaclass(metaclass): + """Class decorator for creating a class with a metaclass.""" + + def wrapper(cls): + orig_vars = cls.__dict__.copy() + slots = orig_vars.get("__slots__") + if slots is not None: + if isinstance(slots, str): + slots = [slots] + for slots_var in slots: + orig_vars.pop(slots_var) + orig_vars.pop("__dict__", None) + orig_vars.pop("__weakref__", None) + if hasattr(cls, "__qualname__"): + orig_vars["__qualname__"] = cls.__qualname__ + return metaclass(cls.__name__, cls.__bases__, orig_vars) + + return wrapper + + +def ensure_binary(s, encoding="utf-8", errors="strict"): + """Coerce **s** to six.binary_type. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> encoded to `bytes` + - `bytes` -> `bytes` + """ + if isinstance(s, binary_type): + return s + if isinstance(s, text_type): + return s.encode(encoding, errors) + raise TypeError("not expecting type '%s'" % type(s)) + + +def ensure_str(s, encoding="utf-8", errors="strict"): + """Coerce *s* to `str`. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + # Optimization: Fast return for the common case. + if type(s) is str: + return s + if PY2 and isinstance(s, text_type): + return s.encode(encoding, errors) + elif PY3 and isinstance(s, binary_type): + return s.decode(encoding, errors) + elif not isinstance(s, (text_type, binary_type)): + raise TypeError("not expecting type '%s'" % type(s)) + return s + + +def ensure_text(s, encoding="utf-8", errors="strict"): + """Coerce *s* to six.text_type. + + For Python 2: + - `unicode` -> `unicode` + - `str` -> `unicode` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + if isinstance(s, binary_type): + return s.decode(encoding, errors) + elif isinstance(s, text_type): + return s + else: + raise TypeError("not expecting type '%s'" % type(s)) + + +def python_2_unicode_compatible(klass): + """ + A class decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if PY2: + if "__str__" not in klass.__dict__: + raise ValueError( + "@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % klass.__name__ + ) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode("utf-8") + return klass + + +# Complete the moves implementation. +# This code is at the end of this module to speed up module loading. +# Turn this module into a package. +__path__ = [] # required for PEP 302 and PEP 451 +__package__ = __name__ # see PEP 366 @ReservedAssignment +if globals().get("__spec__") is not None: + __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable +# Remove other six meta path importers, since they cause problems. This can +# happen if six is removed from sys.modules and then reloaded. (Setuptools does +# this for some reason.) +if sys.meta_path: + for i, importer in enumerate(sys.meta_path): + # Here's some real nastiness: Another "instance" of the six module might + # be floating around. Therefore, we can't use isinstance() to check for + # the six meta path importer, since the other six instance will have + # inserted an importer with different class. + if ( + type(importer).__name__ == "_SixMetaPathImporter" + and importer.name == __name__ + ): + del sys.meta_path[i] + break + del i, importer +# Finally, add the importer to the meta path import hook. +sys.meta_path.append(_importer) diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py new file mode 100644 index 0000000000..ef3fde5206 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py @@ -0,0 +1,24 @@ +import sys + +try: + # Our match_hostname function is the same as 3.10's, so we only want to + # import the match_hostname function if it's at least that good. + # We also fallback on Python 3.10+ because our code doesn't emit + # deprecation warnings and is the same as Python 3.10 otherwise. + if sys.version_info < (3, 5) or sys.version_info >= (3, 10): + raise ImportError("Fallback to vendored code") + + from ssl import CertificateError, match_hostname +except ImportError: + try: + # Backport of the function from a pypi module + from backports.ssl_match_hostname import ( # type: ignore + CertificateError, + match_hostname, + ) + except ImportError: + # Our vendored copy + from ._implementation import CertificateError, match_hostname # type: ignore + +# Not needed, but documenting what we provide. +__all__ = ("CertificateError", "match_hostname") diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py new file mode 100644 index 0000000000..689208d3c6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py @@ -0,0 +1,160 @@ +"""The match_hostname() function from Python 3.3.3, essential when using SSL.""" + +# Note: This file is under the PSF license as the code comes from the python +# stdlib. http://docs.python.org/3/license.html + +import re +import sys + +# ipaddress has been backported to 2.6+ in pypi. If it is installed on the +# system, use it to handle IPAddress ServerAltnames (this was added in +# python-3.5) otherwise only do DNS matching. This allows +# backports.ssl_match_hostname to continue to be used in Python 2.7. +try: + import ipaddress +except ImportError: + ipaddress = None + +__version__ = "3.5.0.1" + + +class CertificateError(ValueError): + pass + + +def _dnsname_match(dn, hostname, max_wildcards=1): + """Matching according to RFC 6125, section 6.4.3 + + http://tools.ietf.org/html/rfc6125#section-6.4.3 + """ + pats = [] + if not dn: + return False + + # Ported from python3-syntax: + # leftmost, *remainder = dn.split(r'.') + parts = dn.split(r".") + leftmost = parts[0] + remainder = parts[1:] + + wildcards = leftmost.count("*") + if wildcards > max_wildcards: + # Issue #17980: avoid denials of service by refusing more + # than one wildcard per fragment. A survey of established + # policy among SSL implementations showed it to be a + # reasonable choice. + raise CertificateError( + "too many wildcards in certificate DNS name: " + repr(dn) + ) + + # speed up common case w/o wildcards + if not wildcards: + return dn.lower() == hostname.lower() + + # RFC 6125, section 6.4.3, subitem 1. + # The client SHOULD NOT attempt to match a presented identifier in which + # the wildcard character comprises a label other than the left-most label. + if leftmost == "*": + # When '*' is a fragment by itself, it matches a non-empty dotless + # fragment. + pats.append("[^.]+") + elif leftmost.startswith("xn--") or hostname.startswith("xn--"): + # RFC 6125, section 6.4.3, subitem 3. + # The client SHOULD NOT attempt to match a presented identifier + # where the wildcard character is embedded within an A-label or + # U-label of an internationalized domain name. + pats.append(re.escape(leftmost)) + else: + # Otherwise, '*' matches any dotless string, e.g. www* + pats.append(re.escape(leftmost).replace(r"\*", "[^.]*")) + + # add the remaining fragments, ignore any wildcards + for frag in remainder: + pats.append(re.escape(frag)) + + pat = re.compile(r"\A" + r"\.".join(pats) + r"\Z", re.IGNORECASE) + return pat.match(hostname) + + +def _to_unicode(obj): + if isinstance(obj, str) and sys.version_info < (3,): + obj = unicode(obj, encoding="ascii", errors="strict") + return obj + + +def _ipaddress_match(ipname, host_ip): + """Exact matching of IP addresses. + + RFC 6125 explicitly doesn't define an algorithm for this + (section 1.7.2 - "Out of Scope"). + """ + # OpenSSL may add a trailing newline to a subjectAltName's IP address + # Divergence from upstream: ipaddress can't handle byte str + ip = ipaddress.ip_address(_to_unicode(ipname).rstrip()) + return ip == host_ip + + +def match_hostname(cert, hostname): + """Verify that *cert* (in decoded format as returned by + SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 and RFC 6125 + rules are followed, but IP addresses are not accepted for *hostname*. + + CertificateError is raised on failure. On success, the function + returns nothing. + """ + if not cert: + raise ValueError( + "empty or no certificate, match_hostname needs a " + "SSL socket or SSL context with either " + "CERT_OPTIONAL or CERT_REQUIRED" + ) + try: + # Divergence from upstream: ipaddress can't handle byte str + host_ip = ipaddress.ip_address(_to_unicode(hostname)) + except ValueError: + # Not an IP address (common case) + host_ip = None + except UnicodeError: + # Divergence from upstream: Have to deal with ipaddress not taking + # byte strings. addresses should be all ascii, so we consider it not + # an ipaddress in this case + host_ip = None + except AttributeError: + # Divergence from upstream: Make ipaddress library optional + if ipaddress is None: + host_ip = None + else: + raise + dnsnames = [] + san = cert.get("subjectAltName", ()) + for key, value in san: + if key == "DNS": + if host_ip is None and _dnsname_match(value, hostname): + return + dnsnames.append(value) + elif key == "IP Address": + if host_ip is not None and _ipaddress_match(value, host_ip): + return + dnsnames.append(value) + if not dnsnames: + # The subject is only checked when there is no dNSName entry + # in subjectAltName + for sub in cert.get("subject", ()): + for key, value in sub: + # XXX according to RFC 2818, the most specific Common Name + # must be used. + if key == "commonName": + if _dnsname_match(value, hostname): + return + dnsnames.append(value) + if len(dnsnames) > 1: + raise CertificateError( + "hostname %r " + "doesn't match either of %s" % (hostname, ", ".join(map(repr, dnsnames))) + ) + elif len(dnsnames) == 1: + raise CertificateError("hostname %r doesn't match %r" % (hostname, dnsnames[0])) + else: + raise CertificateError( + "no appropriate commonName or subjectAltName fields were found" + ) diff --git a/openpype/hosts/fusion/vendor/urllib3/poolmanager.py b/openpype/hosts/fusion/vendor/urllib3/poolmanager.py new file mode 100644 index 0000000000..3a31a285bf --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/poolmanager.py @@ -0,0 +1,536 @@ +from __future__ import absolute_import + +import collections +import functools +import logging + +from ._collections import RecentlyUsedContainer +from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, port_by_scheme +from .exceptions import ( + LocationValueError, + MaxRetryError, + ProxySchemeUnknown, + ProxySchemeUnsupported, + URLSchemeUnknown, +) +from .packages import six +from .packages.six.moves.urllib.parse import urljoin +from .request import RequestMethods +from .util.proxy import connection_requires_http_tunnel +from .util.retry import Retry +from .util.url import parse_url + +__all__ = ["PoolManager", "ProxyManager", "proxy_from_url"] + + +log = logging.getLogger(__name__) + +SSL_KEYWORDS = ( + "key_file", + "cert_file", + "cert_reqs", + "ca_certs", + "ssl_version", + "ca_cert_dir", + "ssl_context", + "key_password", +) + +# All known keyword arguments that could be provided to the pool manager, its +# pools, or the underlying connections. This is used to construct a pool key. +_key_fields = ( + "key_scheme", # str + "key_host", # str + "key_port", # int + "key_timeout", # int or float or Timeout + "key_retries", # int or Retry + "key_strict", # bool + "key_block", # bool + "key_source_address", # str + "key_key_file", # str + "key_key_password", # str + "key_cert_file", # str + "key_cert_reqs", # str + "key_ca_certs", # str + "key_ssl_version", # str + "key_ca_cert_dir", # str + "key_ssl_context", # instance of ssl.SSLContext or urllib3.util.ssl_.SSLContext + "key_maxsize", # int + "key_headers", # dict + "key__proxy", # parsed proxy url + "key__proxy_headers", # dict + "key__proxy_config", # class + "key_socket_options", # list of (level (int), optname (int), value (int or str)) tuples + "key__socks_options", # dict + "key_assert_hostname", # bool or string + "key_assert_fingerprint", # str + "key_server_hostname", # str +) + +#: The namedtuple class used to construct keys for the connection pool. +#: All custom key schemes should include the fields in this key at a minimum. +PoolKey = collections.namedtuple("PoolKey", _key_fields) + +_proxy_config_fields = ("ssl_context", "use_forwarding_for_https") +ProxyConfig = collections.namedtuple("ProxyConfig", _proxy_config_fields) + + +def _default_key_normalizer(key_class, request_context): + """ + Create a pool key out of a request context dictionary. + + According to RFC 3986, both the scheme and host are case-insensitive. + Therefore, this function normalizes both before constructing the pool + key for an HTTPS request. If you wish to change this behaviour, provide + alternate callables to ``key_fn_by_scheme``. + + :param key_class: + The class to use when constructing the key. This should be a namedtuple + with the ``scheme`` and ``host`` keys at a minimum. + :type key_class: namedtuple + :param request_context: + A dictionary-like object that contain the context for a request. + :type request_context: dict + + :return: A namedtuple that can be used as a connection pool key. + :rtype: PoolKey + """ + # Since we mutate the dictionary, make a copy first + context = request_context.copy() + context["scheme"] = context["scheme"].lower() + context["host"] = context["host"].lower() + + # These are both dictionaries and need to be transformed into frozensets + for key in ("headers", "_proxy_headers", "_socks_options"): + if key in context and context[key] is not None: + context[key] = frozenset(context[key].items()) + + # The socket_options key may be a list and needs to be transformed into a + # tuple. + socket_opts = context.get("socket_options") + if socket_opts is not None: + context["socket_options"] = tuple(socket_opts) + + # Map the kwargs to the names in the namedtuple - this is necessary since + # namedtuples can't have fields starting with '_'. + for key in list(context.keys()): + context["key_" + key] = context.pop(key) + + # Default to ``None`` for keys missing from the context + for field in key_class._fields: + if field not in context: + context[field] = None + + return key_class(**context) + + +#: A dictionary that maps a scheme to a callable that creates a pool key. +#: This can be used to alter the way pool keys are constructed, if desired. +#: Each PoolManager makes a copy of this dictionary so they can be configured +#: globally here, or individually on the instance. +key_fn_by_scheme = { + "http": functools.partial(_default_key_normalizer, PoolKey), + "https": functools.partial(_default_key_normalizer, PoolKey), +} + +pool_classes_by_scheme = {"http": HTTPConnectionPool, "https": HTTPSConnectionPool} + + +class PoolManager(RequestMethods): + """ + Allows for arbitrary requests while transparently keeping track of + necessary connection pools for you. + + :param num_pools: + Number of connection pools to cache before discarding the least + recently used pool. + + :param headers: + Headers to include with all requests, unless other headers are given + explicitly. + + :param \\**connection_pool_kw: + Additional parameters are used to create fresh + :class:`urllib3.connectionpool.ConnectionPool` instances. + + Example:: + + >>> manager = PoolManager(num_pools=2) + >>> r = manager.request('GET', 'http://google.com/') + >>> r = manager.request('GET', 'http://google.com/mail') + >>> r = manager.request('GET', 'http://yahoo.com/') + >>> len(manager.pools) + 2 + + """ + + proxy = None + proxy_config = None + + def __init__(self, num_pools=10, headers=None, **connection_pool_kw): + RequestMethods.__init__(self, headers) + self.connection_pool_kw = connection_pool_kw + self.pools = RecentlyUsedContainer(num_pools, dispose_func=lambda p: p.close()) + + # Locally set the pool classes and keys so other PoolManagers can + # override them. + self.pool_classes_by_scheme = pool_classes_by_scheme + self.key_fn_by_scheme = key_fn_by_scheme.copy() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.clear() + # Return False to re-raise any potential exceptions + return False + + def _new_pool(self, scheme, host, port, request_context=None): + """ + Create a new :class:`urllib3.connectionpool.ConnectionPool` based on host, port, scheme, and + any additional pool keyword arguments. + + If ``request_context`` is provided, it is provided as keyword arguments + to the pool class used. This method is used to actually create the + connection pools handed out by :meth:`connection_from_url` and + companion methods. It is intended to be overridden for customization. + """ + pool_cls = self.pool_classes_by_scheme[scheme] + if request_context is None: + request_context = self.connection_pool_kw.copy() + + # Although the context has everything necessary to create the pool, + # this function has historically only used the scheme, host, and port + # in the positional args. When an API change is acceptable these can + # be removed. + for key in ("scheme", "host", "port"): + request_context.pop(key, None) + + if scheme == "http": + for kw in SSL_KEYWORDS: + request_context.pop(kw, None) + + return pool_cls(host, port, **request_context) + + def clear(self): + """ + Empty our store of pools and direct them all to close. + + This will not affect in-flight connections, but they will not be + re-used after completion. + """ + self.pools.clear() + + def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None): + """ + Get a :class:`urllib3.connectionpool.ConnectionPool` based on the host, port, and scheme. + + If ``port`` isn't given, it will be derived from the ``scheme`` using + ``urllib3.connectionpool.port_by_scheme``. If ``pool_kwargs`` is + provided, it is merged with the instance's ``connection_pool_kw`` + variable and used to create the new connection pool, if one is + needed. + """ + + if not host: + raise LocationValueError("No host specified.") + + request_context = self._merge_pool_kwargs(pool_kwargs) + request_context["scheme"] = scheme or "http" + if not port: + port = port_by_scheme.get(request_context["scheme"].lower(), 80) + request_context["port"] = port + request_context["host"] = host + + return self.connection_from_context(request_context) + + def connection_from_context(self, request_context): + """ + Get a :class:`urllib3.connectionpool.ConnectionPool` based on the request context. + + ``request_context`` must at least contain the ``scheme`` key and its + value must be a key in ``key_fn_by_scheme`` instance variable. + """ + scheme = request_context["scheme"].lower() + pool_key_constructor = self.key_fn_by_scheme.get(scheme) + if not pool_key_constructor: + raise URLSchemeUnknown(scheme) + pool_key = pool_key_constructor(request_context) + + return self.connection_from_pool_key(pool_key, request_context=request_context) + + def connection_from_pool_key(self, pool_key, request_context=None): + """ + Get a :class:`urllib3.connectionpool.ConnectionPool` based on the provided pool key. + + ``pool_key`` should be a namedtuple that only contains immutable + objects. At a minimum it must have the ``scheme``, ``host``, and + ``port`` fields. + """ + with self.pools.lock: + # If the scheme, host, or port doesn't match existing open + # connections, open a new ConnectionPool. + pool = self.pools.get(pool_key) + if pool: + return pool + + # Make a fresh ConnectionPool of the desired type + scheme = request_context["scheme"] + host = request_context["host"] + port = request_context["port"] + pool = self._new_pool(scheme, host, port, request_context=request_context) + self.pools[pool_key] = pool + + return pool + + def connection_from_url(self, url, pool_kwargs=None): + """ + Similar to :func:`urllib3.connectionpool.connection_from_url`. + + If ``pool_kwargs`` is not provided and a new pool needs to be + constructed, ``self.connection_pool_kw`` is used to initialize + the :class:`urllib3.connectionpool.ConnectionPool`. If ``pool_kwargs`` + is provided, it is used instead. Note that if a new pool does not + need to be created for the request, the provided ``pool_kwargs`` are + not used. + """ + u = parse_url(url) + return self.connection_from_host( + u.host, port=u.port, scheme=u.scheme, pool_kwargs=pool_kwargs + ) + + def _merge_pool_kwargs(self, override): + """ + Merge a dictionary of override values for self.connection_pool_kw. + + This does not modify self.connection_pool_kw and returns a new dict. + Any keys in the override dictionary with a value of ``None`` are + removed from the merged dictionary. + """ + base_pool_kwargs = self.connection_pool_kw.copy() + if override: + for key, value in override.items(): + if value is None: + try: + del base_pool_kwargs[key] + except KeyError: + pass + else: + base_pool_kwargs[key] = value + return base_pool_kwargs + + def _proxy_requires_url_absolute_form(self, parsed_url): + """ + Indicates if the proxy requires the complete destination URL in the + request. Normally this is only needed when not using an HTTP CONNECT + tunnel. + """ + if self.proxy is None: + return False + + return not connection_requires_http_tunnel( + self.proxy, self.proxy_config, parsed_url.scheme + ) + + def _validate_proxy_scheme_url_selection(self, url_scheme): + """ + Validates that were not attempting to do TLS in TLS connections on + Python2 or with unsupported SSL implementations. + """ + if self.proxy is None or url_scheme != "https": + return + + if self.proxy.scheme != "https": + return + + if six.PY2 and not self.proxy_config.use_forwarding_for_https: + raise ProxySchemeUnsupported( + "Contacting HTTPS destinations through HTTPS proxies " + "'via CONNECT tunnels' is not supported in Python 2" + ) + + def urlopen(self, method, url, redirect=True, **kw): + """ + Same as :meth:`urllib3.HTTPConnectionPool.urlopen` + with custom cross-host redirect logic and only sends the request-uri + portion of the ``url``. + + The given ``url`` parameter must be absolute, such that an appropriate + :class:`urllib3.connectionpool.ConnectionPool` can be chosen for it. + """ + u = parse_url(url) + self._validate_proxy_scheme_url_selection(u.scheme) + + conn = self.connection_from_host(u.host, port=u.port, scheme=u.scheme) + + kw["assert_same_host"] = False + kw["redirect"] = False + + if "headers" not in kw: + kw["headers"] = self.headers.copy() + + if self._proxy_requires_url_absolute_form(u): + response = conn.urlopen(method, url, **kw) + else: + response = conn.urlopen(method, u.request_uri, **kw) + + redirect_location = redirect and response.get_redirect_location() + if not redirect_location: + return response + + # Support relative URLs for redirecting. + redirect_location = urljoin(url, redirect_location) + + # RFC 7231, Section 6.4.4 + if response.status == 303: + method = "GET" + + retries = kw.get("retries") + if not isinstance(retries, Retry): + retries = Retry.from_int(retries, redirect=redirect) + + # Strip headers marked as unsafe to forward to the redirected location. + # Check remove_headers_on_redirect to avoid a potential network call within + # conn.is_same_host() which may use socket.gethostbyname() in the future. + if retries.remove_headers_on_redirect and not conn.is_same_host( + redirect_location + ): + headers = list(six.iterkeys(kw["headers"])) + for header in headers: + if header.lower() in retries.remove_headers_on_redirect: + kw["headers"].pop(header, None) + + try: + retries = retries.increment(method, url, response=response, _pool=conn) + except MaxRetryError: + if retries.raise_on_redirect: + response.drain_conn() + raise + return response + + kw["retries"] = retries + kw["redirect"] = redirect + + log.info("Redirecting %s -> %s", url, redirect_location) + + response.drain_conn() + return self.urlopen(method, redirect_location, **kw) + + +class ProxyManager(PoolManager): + """ + Behaves just like :class:`PoolManager`, but sends all requests through + the defined proxy, using the CONNECT method for HTTPS URLs. + + :param proxy_url: + The URL of the proxy to be used. + + :param proxy_headers: + A dictionary containing headers that will be sent to the proxy. In case + of HTTP they are being sent with each request, while in the + HTTPS/CONNECT case they are sent only once. Could be used for proxy + authentication. + + :param proxy_ssl_context: + The proxy SSL context is used to establish the TLS connection to the + proxy when using HTTPS proxies. + + :param use_forwarding_for_https: + (Defaults to False) If set to True will forward requests to the HTTPS + proxy to be made on behalf of the client instead of creating a TLS + tunnel via the CONNECT method. **Enabling this flag means that request + and response headers and content will be visible from the HTTPS proxy** + whereas tunneling keeps request and response headers and content + private. IP address, target hostname, SNI, and port are always visible + to an HTTPS proxy even when this flag is disabled. + + Example: + >>> proxy = urllib3.ProxyManager('http://localhost:3128/') + >>> r1 = proxy.request('GET', 'http://google.com/') + >>> r2 = proxy.request('GET', 'http://httpbin.org/') + >>> len(proxy.pools) + 1 + >>> r3 = proxy.request('GET', 'https://httpbin.org/') + >>> r4 = proxy.request('GET', 'https://twitter.com/') + >>> len(proxy.pools) + 3 + + """ + + def __init__( + self, + proxy_url, + num_pools=10, + headers=None, + proxy_headers=None, + proxy_ssl_context=None, + use_forwarding_for_https=False, + **connection_pool_kw + ): + + if isinstance(proxy_url, HTTPConnectionPool): + proxy_url = "%s://%s:%i" % ( + proxy_url.scheme, + proxy_url.host, + proxy_url.port, + ) + proxy = parse_url(proxy_url) + + if proxy.scheme not in ("http", "https"): + raise ProxySchemeUnknown(proxy.scheme) + + if not proxy.port: + port = port_by_scheme.get(proxy.scheme, 80) + proxy = proxy._replace(port=port) + + self.proxy = proxy + self.proxy_headers = proxy_headers or {} + self.proxy_ssl_context = proxy_ssl_context + self.proxy_config = ProxyConfig(proxy_ssl_context, use_forwarding_for_https) + + connection_pool_kw["_proxy"] = self.proxy + connection_pool_kw["_proxy_headers"] = self.proxy_headers + connection_pool_kw["_proxy_config"] = self.proxy_config + + super(ProxyManager, self).__init__(num_pools, headers, **connection_pool_kw) + + def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None): + if scheme == "https": + return super(ProxyManager, self).connection_from_host( + host, port, scheme, pool_kwargs=pool_kwargs + ) + + return super(ProxyManager, self).connection_from_host( + self.proxy.host, self.proxy.port, self.proxy.scheme, pool_kwargs=pool_kwargs + ) + + def _set_proxy_headers(self, url, headers=None): + """ + Sets headers needed by proxies: specifically, the Accept and Host + headers. Only sets headers not provided by the user. + """ + headers_ = {"Accept": "*/*"} + + netloc = parse_url(url).netloc + if netloc: + headers_["Host"] = netloc + + if headers: + headers_.update(headers) + return headers_ + + def urlopen(self, method, url, redirect=True, **kw): + "Same as HTTP(S)ConnectionPool.urlopen, ``url`` must be absolute." + u = parse_url(url) + if not connection_requires_http_tunnel(self.proxy, self.proxy_config, u.scheme): + # For connections using HTTP CONNECT, httplib sets the necessary + # headers on the CONNECT to the proxy. If we're not using CONNECT, + # we'll definitely need to set 'Host' at the very least. + headers = kw.get("headers", self.headers) + kw["headers"] = self._set_proxy_headers(url, headers) + + return super(ProxyManager, self).urlopen(method, url, redirect=redirect, **kw) + + +def proxy_from_url(url, **kw): + return ProxyManager(proxy_url=url, **kw) diff --git a/openpype/hosts/fusion/vendor/urllib3/request.py b/openpype/hosts/fusion/vendor/urllib3/request.py new file mode 100644 index 0000000000..398386a5b9 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/request.py @@ -0,0 +1,170 @@ +from __future__ import absolute_import + +from .filepost import encode_multipart_formdata +from .packages.six.moves.urllib.parse import urlencode + +__all__ = ["RequestMethods"] + + +class RequestMethods(object): + """ + Convenience mixin for classes who implement a :meth:`urlopen` method, such + as :class:`urllib3.HTTPConnectionPool` and + :class:`urllib3.PoolManager`. + + Provides behavior for making common types of HTTP request methods and + decides which type of request field encoding to use. + + Specifically, + + :meth:`.request_encode_url` is for sending requests whose fields are + encoded in the URL (such as GET, HEAD, DELETE). + + :meth:`.request_encode_body` is for sending requests whose fields are + encoded in the *body* of the request using multipart or www-form-urlencoded + (such as for POST, PUT, PATCH). + + :meth:`.request` is for making any kind of request, it will look up the + appropriate encoding format and use one of the above two methods to make + the request. + + Initializer parameters: + + :param headers: + Headers to include with all requests, unless other headers are given + explicitly. + """ + + _encode_url_methods = {"DELETE", "GET", "HEAD", "OPTIONS"} + + def __init__(self, headers=None): + self.headers = headers or {} + + def urlopen( + self, + method, + url, + body=None, + headers=None, + encode_multipart=True, + multipart_boundary=None, + **kw + ): # Abstract + raise NotImplementedError( + "Classes extending RequestMethods must implement " + "their own ``urlopen`` method." + ) + + def request(self, method, url, fields=None, headers=None, **urlopen_kw): + """ + Make a request using :meth:`urlopen` with the appropriate encoding of + ``fields`` based on the ``method`` used. + + This is a convenience method that requires the least amount of manual + effort. It can be used in most situations, while still having the + option to drop down to more specific methods when necessary, such as + :meth:`request_encode_url`, :meth:`request_encode_body`, + or even the lowest level :meth:`urlopen`. + """ + method = method.upper() + + urlopen_kw["request_url"] = url + + if method in self._encode_url_methods: + return self.request_encode_url( + method, url, fields=fields, headers=headers, **urlopen_kw + ) + else: + return self.request_encode_body( + method, url, fields=fields, headers=headers, **urlopen_kw + ) + + def request_encode_url(self, method, url, fields=None, headers=None, **urlopen_kw): + """ + Make a request using :meth:`urlopen` with the ``fields`` encoded in + the url. This is useful for request methods like GET, HEAD, DELETE, etc. + """ + if headers is None: + headers = self.headers + + extra_kw = {"headers": headers} + extra_kw.update(urlopen_kw) + + if fields: + url += "?" + urlencode(fields) + + return self.urlopen(method, url, **extra_kw) + + def request_encode_body( + self, + method, + url, + fields=None, + headers=None, + encode_multipart=True, + multipart_boundary=None, + **urlopen_kw + ): + """ + Make a request using :meth:`urlopen` with the ``fields`` encoded in + the body. This is useful for request methods like POST, PUT, PATCH, etc. + + When ``encode_multipart=True`` (default), then + :func:`urllib3.encode_multipart_formdata` is used to encode + the payload with the appropriate content type. Otherwise + :func:`urllib.parse.urlencode` is used with the + 'application/x-www-form-urlencoded' content type. + + Multipart encoding must be used when posting files, and it's reasonably + safe to use it in other times too. However, it may break request + signing, such as with OAuth. + + Supports an optional ``fields`` parameter of key/value strings AND + key/filetuple. A filetuple is a (filename, data, MIME type) tuple where + the MIME type is optional. For example:: + + fields = { + 'foo': 'bar', + 'fakefile': ('foofile.txt', 'contents of foofile'), + 'realfile': ('barfile.txt', open('realfile').read()), + 'typedfile': ('bazfile.bin', open('bazfile').read(), + 'image/jpeg'), + 'nonamefile': 'contents of nonamefile field', + } + + When uploading a file, providing a filename (the first parameter of the + tuple) is optional but recommended to best mimic behavior of browsers. + + Note that if ``headers`` are supplied, the 'Content-Type' header will + be overwritten because it depends on the dynamic random boundary string + which is used to compose the body of the request. The random boundary + string can be explicitly set with the ``multipart_boundary`` parameter. + """ + if headers is None: + headers = self.headers + + extra_kw = {"headers": {}} + + if fields: + if "body" in urlopen_kw: + raise TypeError( + "request got values for both 'fields' and 'body', can only specify one." + ) + + if encode_multipart: + body, content_type = encode_multipart_formdata( + fields, boundary=multipart_boundary + ) + else: + body, content_type = ( + urlencode(fields), + "application/x-www-form-urlencoded", + ) + + extra_kw["body"] = body + extra_kw["headers"] = {"Content-Type": content_type} + + extra_kw["headers"].update(headers) + extra_kw.update(urlopen_kw) + + return self.urlopen(method, url, **extra_kw) diff --git a/openpype/hosts/fusion/vendor/urllib3/response.py b/openpype/hosts/fusion/vendor/urllib3/response.py new file mode 100644 index 0000000000..38693f4fc6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/response.py @@ -0,0 +1,821 @@ +from __future__ import absolute_import + +import io +import logging +import zlib +from contextlib import contextmanager +from socket import error as SocketError +from socket import timeout as SocketTimeout + +try: + import brotli +except ImportError: + brotli = None + +from ._collections import HTTPHeaderDict +from .connection import BaseSSLError, HTTPException +from .exceptions import ( + BodyNotHttplibCompatible, + DecodeError, + HTTPError, + IncompleteRead, + InvalidChunkLength, + InvalidHeader, + ProtocolError, + ReadTimeoutError, + ResponseNotChunked, + SSLError, +) +from .packages import six +from .util.response import is_fp_closed, is_response_to_head + +log = logging.getLogger(__name__) + + +class DeflateDecoder(object): + def __init__(self): + self._first_try = True + self._data = b"" + self._obj = zlib.decompressobj() + + def __getattr__(self, name): + return getattr(self._obj, name) + + def decompress(self, data): + if not data: + return data + + if not self._first_try: + return self._obj.decompress(data) + + self._data += data + try: + decompressed = self._obj.decompress(data) + if decompressed: + self._first_try = False + self._data = None + return decompressed + except zlib.error: + self._first_try = False + self._obj = zlib.decompressobj(-zlib.MAX_WBITS) + try: + return self.decompress(self._data) + finally: + self._data = None + + +class GzipDecoderState(object): + + FIRST_MEMBER = 0 + OTHER_MEMBERS = 1 + SWALLOW_DATA = 2 + + +class GzipDecoder(object): + def __init__(self): + self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) + self._state = GzipDecoderState.FIRST_MEMBER + + def __getattr__(self, name): + return getattr(self._obj, name) + + def decompress(self, data): + ret = bytearray() + if self._state == GzipDecoderState.SWALLOW_DATA or not data: + return bytes(ret) + while True: + try: + ret += self._obj.decompress(data) + except zlib.error: + previous_state = self._state + # Ignore data after the first error + self._state = GzipDecoderState.SWALLOW_DATA + if previous_state == GzipDecoderState.OTHER_MEMBERS: + # Allow trailing garbage acceptable in other gzip clients + return bytes(ret) + raise + data = self._obj.unused_data + if not data: + return bytes(ret) + self._state = GzipDecoderState.OTHER_MEMBERS + self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) + + +if brotli is not None: + + class BrotliDecoder(object): + # Supports both 'brotlipy' and 'Brotli' packages + # since they share an import name. The top branches + # are for 'brotlipy' and bottom branches for 'Brotli' + def __init__(self): + self._obj = brotli.Decompressor() + if hasattr(self._obj, "decompress"): + self.decompress = self._obj.decompress + else: + self.decompress = self._obj.process + + def flush(self): + if hasattr(self._obj, "flush"): + return self._obj.flush() + return b"" + + +class MultiDecoder(object): + """ + From RFC7231: + If one or more encodings have been applied to a representation, the + sender that applied the encodings MUST generate a Content-Encoding + header field that lists the content codings in the order in which + they were applied. + """ + + def __init__(self, modes): + self._decoders = [_get_decoder(m.strip()) for m in modes.split(",")] + + def flush(self): + return self._decoders[0].flush() + + def decompress(self, data): + for d in reversed(self._decoders): + data = d.decompress(data) + return data + + +def _get_decoder(mode): + if "," in mode: + return MultiDecoder(mode) + + if mode == "gzip": + return GzipDecoder() + + if brotli is not None and mode == "br": + return BrotliDecoder() + + return DeflateDecoder() + + +class HTTPResponse(io.IOBase): + """ + HTTP Response container. + + Backwards-compatible with :class:`http.client.HTTPResponse` but the response ``body`` is + loaded and decoded on-demand when the ``data`` property is accessed. This + class is also compatible with the Python standard library's :mod:`io` + module, and can hence be treated as a readable object in the context of that + framework. + + Extra parameters for behaviour not present in :class:`http.client.HTTPResponse`: + + :param preload_content: + If True, the response's body will be preloaded during construction. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + + :param original_response: + When this HTTPResponse wrapper is generated from an :class:`http.client.HTTPResponse` + object, it's convenient to include the original for debug purposes. It's + otherwise unused. + + :param retries: + The retries contains the last :class:`~urllib3.util.retry.Retry` that + was used during the request. + + :param enforce_content_length: + Enforce content length checking. Body returned by server must match + value of Content-Length header, if present. Otherwise, raise error. + """ + + CONTENT_DECODERS = ["gzip", "deflate"] + if brotli is not None: + CONTENT_DECODERS += ["br"] + REDIRECT_STATUSES = [301, 302, 303, 307, 308] + + def __init__( + self, + body="", + headers=None, + status=0, + version=0, + reason=None, + strict=0, + preload_content=True, + decode_content=True, + original_response=None, + pool=None, + connection=None, + msg=None, + retries=None, + enforce_content_length=False, + request_method=None, + request_url=None, + auto_close=True, + ): + + if isinstance(headers, HTTPHeaderDict): + self.headers = headers + else: + self.headers = HTTPHeaderDict(headers) + self.status = status + self.version = version + self.reason = reason + self.strict = strict + self.decode_content = decode_content + self.retries = retries + self.enforce_content_length = enforce_content_length + self.auto_close = auto_close + + self._decoder = None + self._body = None + self._fp = None + self._original_response = original_response + self._fp_bytes_read = 0 + self.msg = msg + self._request_url = request_url + + if body and isinstance(body, (six.string_types, bytes)): + self._body = body + + self._pool = pool + self._connection = connection + + if hasattr(body, "read"): + self._fp = body + + # Are we using the chunked-style of transfer encoding? + self.chunked = False + self.chunk_left = None + tr_enc = self.headers.get("transfer-encoding", "").lower() + # Don't incur the penalty of creating a list and then discarding it + encodings = (enc.strip() for enc in tr_enc.split(",")) + if "chunked" in encodings: + self.chunked = True + + # Determine length of response + self.length_remaining = self._init_length(request_method) + + # If requested, preload the body. + if preload_content and not self._body: + self._body = self.read(decode_content=decode_content) + + def get_redirect_location(self): + """ + Should we redirect and where to? + + :returns: Truthy redirect location string if we got a redirect status + code and valid location. ``None`` if redirect status and no + location. ``False`` if not a redirect status code. + """ + if self.status in self.REDIRECT_STATUSES: + return self.headers.get("location") + + return False + + def release_conn(self): + if not self._pool or not self._connection: + return + + self._pool._put_conn(self._connection) + self._connection = None + + def drain_conn(self): + """ + Read and discard any remaining HTTP response data in the response connection. + + Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. + """ + try: + self.read() + except (HTTPError, SocketError, BaseSSLError, HTTPException): + pass + + @property + def data(self): + # For backwards-compat with earlier urllib3 0.4 and earlier. + if self._body: + return self._body + + if self._fp: + return self.read(cache_content=True) + + @property + def connection(self): + return self._connection + + def isclosed(self): + return is_fp_closed(self._fp) + + def tell(self): + """ + Obtain the number of bytes pulled over the wire so far. May differ from + the amount of content returned by :meth:``urllib3.response.HTTPResponse.read`` + if bytes are encoded on the wire (e.g, compressed). + """ + return self._fp_bytes_read + + def _init_length(self, request_method): + """ + Set initial length value for Response content if available. + """ + length = self.headers.get("content-length") + + if length is not None: + if self.chunked: + # This Response will fail with an IncompleteRead if it can't be + # received as chunked. This method falls back to attempt reading + # the response before raising an exception. + log.warning( + "Received response with both Content-Length and " + "Transfer-Encoding set. This is expressly forbidden " + "by RFC 7230 sec 3.3.2. Ignoring Content-Length and " + "attempting to process response as Transfer-Encoding: " + "chunked." + ) + return None + + try: + # RFC 7230 section 3.3.2 specifies multiple content lengths can + # be sent in a single Content-Length header + # (e.g. Content-Length: 42, 42). This line ensures the values + # are all valid ints and that as long as the `set` length is 1, + # all values are the same. Otherwise, the header is invalid. + lengths = set([int(val) for val in length.split(",")]) + if len(lengths) > 1: + raise InvalidHeader( + "Content-Length contained multiple " + "unmatching values (%s)" % length + ) + length = lengths.pop() + except ValueError: + length = None + else: + if length < 0: + length = None + + # Convert status to int for comparison + # In some cases, httplib returns a status of "_UNKNOWN" + try: + status = int(self.status) + except ValueError: + status = 0 + + # Check for responses that shouldn't include a body + if status in (204, 304) or 100 <= status < 200 or request_method == "HEAD": + length = 0 + + return length + + def _init_decoder(self): + """ + Set-up the _decoder attribute if necessary. + """ + # Note: content-encoding value should be case-insensitive, per RFC 7230 + # Section 3.2 + content_encoding = self.headers.get("content-encoding", "").lower() + if self._decoder is None: + if content_encoding in self.CONTENT_DECODERS: + self._decoder = _get_decoder(content_encoding) + elif "," in content_encoding: + encodings = [ + e.strip() + for e in content_encoding.split(",") + if e.strip() in self.CONTENT_DECODERS + ] + if len(encodings): + self._decoder = _get_decoder(content_encoding) + + DECODER_ERROR_CLASSES = (IOError, zlib.error) + if brotli is not None: + DECODER_ERROR_CLASSES += (brotli.error,) + + def _decode(self, data, decode_content, flush_decoder): + """ + Decode the data passed in and potentially flush the decoder. + """ + if not decode_content: + return data + + try: + if self._decoder: + data = self._decoder.decompress(data) + except self.DECODER_ERROR_CLASSES as e: + content_encoding = self.headers.get("content-encoding", "").lower() + raise DecodeError( + "Received response with content-encoding: %s, but " + "failed to decode it." % content_encoding, + e, + ) + if flush_decoder: + data += self._flush_decoder() + + return data + + def _flush_decoder(self): + """ + Flushes the decoder. Should only be called if the decoder is actually + being used. + """ + if self._decoder: + buf = self._decoder.decompress(b"") + return buf + self._decoder.flush() + + return b"" + + @contextmanager + def _error_catcher(self): + """ + Catch low-level python exceptions, instead re-raising urllib3 + variants, so that low-level exceptions are not leaked in the + high-level api. + + On exit, release the connection back to the pool. + """ + clean_exit = False + + try: + try: + yield + + except SocketTimeout: + # FIXME: Ideally we'd like to include the url in the ReadTimeoutError but + # there is yet no clean way to get at it from this context. + raise ReadTimeoutError(self._pool, None, "Read timed out.") + + except BaseSSLError as e: + # FIXME: Is there a better way to differentiate between SSLErrors? + if "read operation timed out" not in str(e): + # SSL errors related to framing/MAC get wrapped and reraised here + raise SSLError(e) + + raise ReadTimeoutError(self._pool, None, "Read timed out.") + + except (HTTPException, SocketError) as e: + # This includes IncompleteRead. + raise ProtocolError("Connection broken: %r" % e, e) + + # If no exception is thrown, we should avoid cleaning up + # unnecessarily. + clean_exit = True + finally: + # If we didn't terminate cleanly, we need to throw away our + # connection. + if not clean_exit: + # The response may not be closed but we're not going to use it + # anymore so close it now to ensure that the connection is + # released back to the pool. + if self._original_response: + self._original_response.close() + + # Closing the response may not actually be sufficient to close + # everything, so if we have a hold of the connection close that + # too. + if self._connection: + self._connection.close() + + # If we hold the original response but it's closed now, we should + # return the connection back to the pool. + if self._original_response and self._original_response.isclosed(): + self.release_conn() + + def read(self, amt=None, decode_content=None, cache_content=False): + """ + Similar to :meth:`http.client.HTTPResponse.read`, but with two additional + parameters: ``decode_content`` and ``cache_content``. + + :param amt: + How much of the content to read. If specified, caching is skipped + because it doesn't make sense to cache partial content as the full + response. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + + :param cache_content: + If True, will save the returned data such that the same result is + returned despite of the state of the underlying file object. This + is useful if you want the ``.data`` property to continue working + after having ``.read()`` the file object. (Overridden if ``amt`` is + set.) + """ + self._init_decoder() + if decode_content is None: + decode_content = self.decode_content + + if self._fp is None: + return + + flush_decoder = False + fp_closed = getattr(self._fp, "closed", False) + + with self._error_catcher(): + if amt is None: + # cStringIO doesn't like amt=None + data = self._fp.read() if not fp_closed else b"" + flush_decoder = True + else: + cache_content = False + data = self._fp.read(amt) if not fp_closed else b"" + if ( + amt != 0 and not data + ): # Platform-specific: Buggy versions of Python. + # Close the connection when no data is returned + # + # This is redundant to what httplib/http.client _should_ + # already do. However, versions of python released before + # December 15, 2012 (http://bugs.python.org/issue16298) do + # not properly close the connection in all cases. There is + # no harm in redundantly calling close. + self._fp.close() + flush_decoder = True + if self.enforce_content_length and self.length_remaining not in ( + 0, + None, + ): + # This is an edge case that httplib failed to cover due + # to concerns of backward compatibility. We're + # addressing it here to make sure IncompleteRead is + # raised during streaming, so all calls with incorrect + # Content-Length are caught. + raise IncompleteRead(self._fp_bytes_read, self.length_remaining) + + if data: + self._fp_bytes_read += len(data) + if self.length_remaining is not None: + self.length_remaining -= len(data) + + data = self._decode(data, decode_content, flush_decoder) + + if cache_content: + self._body = data + + return data + + def stream(self, amt=2 ** 16, decode_content=None): + """ + A generator wrapper for the read() method. A call will block until + ``amt`` bytes have been read from the connection or until the + connection is closed. + + :param amt: + How much of the content to read. The generator will return up to + much data per iteration, but may return less. This is particularly + likely when using compressed data. However, the empty string will + never be returned. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + """ + if self.chunked and self.supports_chunked_reads(): + for line in self.read_chunked(amt, decode_content=decode_content): + yield line + else: + while not is_fp_closed(self._fp): + data = self.read(amt=amt, decode_content=decode_content) + + if data: + yield data + + @classmethod + def from_httplib(ResponseCls, r, **response_kw): + """ + Given an :class:`http.client.HTTPResponse` instance ``r``, return a + corresponding :class:`urllib3.response.HTTPResponse` object. + + Remaining parameters are passed to the HTTPResponse constructor, along + with ``original_response=r``. + """ + headers = r.msg + + if not isinstance(headers, HTTPHeaderDict): + if six.PY2: + # Python 2.7 + headers = HTTPHeaderDict.from_httplib(headers) + else: + headers = HTTPHeaderDict(headers.items()) + + # HTTPResponse objects in Python 3 don't have a .strict attribute + strict = getattr(r, "strict", 0) + resp = ResponseCls( + body=r, + headers=headers, + status=r.status, + version=r.version, + reason=r.reason, + strict=strict, + original_response=r, + **response_kw + ) + return resp + + # Backwards-compatibility methods for http.client.HTTPResponse + def getheaders(self): + return self.headers + + def getheader(self, name, default=None): + return self.headers.get(name, default) + + # Backwards compatibility for http.cookiejar + def info(self): + return self.headers + + # Overrides from io.IOBase + def close(self): + if not self.closed: + self._fp.close() + + if self._connection: + self._connection.close() + + if not self.auto_close: + io.IOBase.close(self) + + @property + def closed(self): + if not self.auto_close: + return io.IOBase.closed.__get__(self) + elif self._fp is None: + return True + elif hasattr(self._fp, "isclosed"): + return self._fp.isclosed() + elif hasattr(self._fp, "closed"): + return self._fp.closed + else: + return True + + def fileno(self): + if self._fp is None: + raise IOError("HTTPResponse has no file to get a fileno from") + elif hasattr(self._fp, "fileno"): + return self._fp.fileno() + else: + raise IOError( + "The file-like object this HTTPResponse is wrapped " + "around has no file descriptor" + ) + + def flush(self): + if ( + self._fp is not None + and hasattr(self._fp, "flush") + and not getattr(self._fp, "closed", False) + ): + return self._fp.flush() + + def readable(self): + # This method is required for `io` module compatibility. + return True + + def readinto(self, b): + # This method is required for `io` module compatibility. + temp = self.read(len(b)) + if len(temp) == 0: + return 0 + else: + b[: len(temp)] = temp + return len(temp) + + def supports_chunked_reads(self): + """ + Checks if the underlying file-like object looks like a + :class:`http.client.HTTPResponse` object. We do this by testing for + the fp attribute. If it is present we assume it returns raw chunks as + processed by read_chunked(). + """ + return hasattr(self._fp, "fp") + + def _update_chunk_length(self): + # First, we'll figure out length of a chunk and then + # we'll try to read it from socket. + if self.chunk_left is not None: + return + line = self._fp.fp.readline() + line = line.split(b";", 1)[0] + try: + self.chunk_left = int(line, 16) + except ValueError: + # Invalid chunked protocol response, abort. + self.close() + raise InvalidChunkLength(self, line) + + def _handle_chunk(self, amt): + returned_chunk = None + if amt is None: + chunk = self._fp._safe_read(self.chunk_left) + returned_chunk = chunk + self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. + self.chunk_left = None + elif amt < self.chunk_left: + value = self._fp._safe_read(amt) + self.chunk_left = self.chunk_left - amt + returned_chunk = value + elif amt == self.chunk_left: + value = self._fp._safe_read(amt) + self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. + self.chunk_left = None + returned_chunk = value + else: # amt > self.chunk_left + returned_chunk = self._fp._safe_read(self.chunk_left) + self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. + self.chunk_left = None + return returned_chunk + + def read_chunked(self, amt=None, decode_content=None): + """ + Similar to :meth:`HTTPResponse.read`, but with an additional + parameter: ``decode_content``. + + :param amt: + How much of the content to read. If specified, caching is skipped + because it doesn't make sense to cache partial content as the full + response. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + """ + self._init_decoder() + # FIXME: Rewrite this method and make it a class with a better structured logic. + if not self.chunked: + raise ResponseNotChunked( + "Response is not chunked. " + "Header 'transfer-encoding: chunked' is missing." + ) + if not self.supports_chunked_reads(): + raise BodyNotHttplibCompatible( + "Body should be http.client.HTTPResponse like. " + "It should have have an fp attribute which returns raw chunks." + ) + + with self._error_catcher(): + # Don't bother reading the body of a HEAD request. + if self._original_response and is_response_to_head(self._original_response): + self._original_response.close() + return + + # If a response is already read and closed + # then return immediately. + if self._fp.fp is None: + return + + while True: + self._update_chunk_length() + if self.chunk_left == 0: + break + chunk = self._handle_chunk(amt) + decoded = self._decode( + chunk, decode_content=decode_content, flush_decoder=False + ) + if decoded: + yield decoded + + if decode_content: + # On CPython and PyPy, we should never need to flush the + # decoder. However, on Jython we *might* need to, so + # lets defensively do it anyway. + decoded = self._flush_decoder() + if decoded: # Platform-specific: Jython. + yield decoded + + # Chunk content ends with \r\n: discard it. + while True: + line = self._fp.fp.readline() + if not line: + # Some sites may not end with '\r\n'. + break + if line == b"\r\n": + break + + # We read everything; close the "file". + if self._original_response: + self._original_response.close() + + def geturl(self): + """ + Returns the URL that was the source of this response. + If the request that generated this response redirected, this method + will return the final redirect location. + """ + if self.retries is not None and len(self.retries.history): + return self.retries.history[-1].redirect_location + else: + return self._request_url + + def __iter__(self): + buffer = [] + for chunk in self.stream(decode_content=True): + if b"\n" in chunk: + chunk = chunk.split(b"\n") + yield b"".join(buffer) + chunk[0] + b"\n" + for x in chunk[1:-1]: + yield x + b"\n" + if chunk[-1]: + buffer = [chunk[-1]] + else: + buffer = [] + else: + buffer.append(chunk) + if buffer: + yield b"".join(buffer) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/__init__.py b/openpype/hosts/fusion/vendor/urllib3/util/__init__.py new file mode 100644 index 0000000000..4547fc522b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/__init__.py @@ -0,0 +1,49 @@ +from __future__ import absolute_import + +# For backwards compatibility, provide imports that used to be here. +from .connection import is_connection_dropped +from .request import SKIP_HEADER, SKIPPABLE_HEADERS, make_headers +from .response import is_fp_closed +from .retry import Retry +from .ssl_ import ( + ALPN_PROTOCOLS, + HAS_SNI, + IS_PYOPENSSL, + IS_SECURETRANSPORT, + PROTOCOL_TLS, + SSLContext, + assert_fingerprint, + resolve_cert_reqs, + resolve_ssl_version, + ssl_wrap_socket, +) +from .timeout import Timeout, current_time +from .url import Url, get_host, parse_url, split_first +from .wait import wait_for_read, wait_for_write + +__all__ = ( + "HAS_SNI", + "IS_PYOPENSSL", + "IS_SECURETRANSPORT", + "SSLContext", + "PROTOCOL_TLS", + "ALPN_PROTOCOLS", + "Retry", + "Timeout", + "Url", + "assert_fingerprint", + "current_time", + "is_connection_dropped", + "is_fp_closed", + "get_host", + "parse_url", + "make_headers", + "resolve_cert_reqs", + "resolve_ssl_version", + "split_first", + "ssl_wrap_socket", + "wait_for_read", + "wait_for_write", + "SKIP_HEADER", + "SKIPPABLE_HEADERS", +) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/connection.py b/openpype/hosts/fusion/vendor/urllib3/util/connection.py new file mode 100644 index 0000000000..bdc240c50c --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/connection.py @@ -0,0 +1,150 @@ +from __future__ import absolute_import + +import socket + +from urllib3.exceptions import LocationParseError + +from ..contrib import _appengine_environ +from ..packages import six +from .wait import NoWayToWaitForSocketError, wait_for_read + + +def is_connection_dropped(conn): # Platform-specific + """ + Returns True if the connection is dropped and should be closed. + + :param conn: + :class:`http.client.HTTPConnection` object. + + Note: For platforms like AppEngine, this will always return ``False`` to + let the platform handle connection recycling transparently for us. + """ + sock = getattr(conn, "sock", False) + if sock is False: # Platform-specific: AppEngine + return False + if sock is None: # Connection already closed (such as by httplib). + return True + try: + # Returns True if readable, which here means it's been dropped + return wait_for_read(sock, timeout=0.0) + except NoWayToWaitForSocketError: # Platform-specific: AppEngine + return False + + +# This function is copied from socket.py in the Python 2.7 standard +# library test suite. Added to its signature is only `socket_options`. +# One additional modification is that we avoid binding to IPv6 servers +# discovered in DNS if the system doesn't have IPv6 functionality. +def create_connection( + address, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + source_address=None, + socket_options=None, +): + """Connect to *address* and return the socket object. + + Convenience function. Connect to *address* (a 2-tuple ``(host, + port)``) and return the socket object. Passing the optional + *timeout* parameter will set the timeout on the socket instance + before attempting to connect. If no *timeout* is supplied, the + global default timeout setting returned by :func:`socket.getdefaulttimeout` + is used. If *source_address* is set it must be a tuple of (host, port) + for the socket to bind as a source address before making the connection. + An host of '' or port 0 tells the OS to use the default. + """ + + host, port = address + if host.startswith("["): + host = host.strip("[]") + err = None + + # Using the value from allowed_gai_family() in the context of getaddrinfo lets + # us select whether to work with IPv4 DNS records, IPv6 records, or both. + # The original create_connection function always returns all records. + family = allowed_gai_family() + + try: + host.encode("idna") + except UnicodeError: + return six.raise_from( + LocationParseError(u"'%s', label empty or too long" % host), None + ) + + for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + + # If provided, set socket level options before connecting. + _set_socket_options(sock, socket_options) + + if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: + sock.settimeout(timeout) + if source_address: + sock.bind(source_address) + sock.connect(sa) + return sock + + except socket.error as e: + err = e + if sock is not None: + sock.close() + sock = None + + if err is not None: + raise err + + raise socket.error("getaddrinfo returns an empty list") + + +def _set_socket_options(sock, options): + if options is None: + return + + for opt in options: + sock.setsockopt(*opt) + + +def allowed_gai_family(): + """This function is designed to work in the context of + getaddrinfo, where family=socket.AF_UNSPEC is the default and + will perform a DNS search for both IPv6 and IPv4 records.""" + + family = socket.AF_INET + if HAS_IPV6: + family = socket.AF_UNSPEC + return family + + +def _has_ipv6(host): + """Returns True if the system can bind an IPv6 address.""" + sock = None + has_ipv6 = False + + # App Engine doesn't support IPV6 sockets and actually has a quota on the + # number of sockets that can be used, so just early out here instead of + # creating a socket needlessly. + # See https://github.com/urllib3/urllib3/issues/1446 + if _appengine_environ.is_appengine_sandbox(): + return False + + if socket.has_ipv6: + # has_ipv6 returns true if cPython was compiled with IPv6 support. + # It does not tell us if the system has IPv6 support enabled. To + # determine that we must bind to an IPv6 address. + # https://github.com/urllib3/urllib3/pull/611 + # https://bugs.python.org/issue658327 + try: + sock = socket.socket(socket.AF_INET6) + sock.bind((host, 0)) + has_ipv6 = True + except Exception: + pass + + if sock: + sock.close() + return has_ipv6 + + +HAS_IPV6 = _has_ipv6("::1") diff --git a/openpype/hosts/fusion/vendor/urllib3/util/proxy.py b/openpype/hosts/fusion/vendor/urllib3/util/proxy.py new file mode 100644 index 0000000000..34f884d5b3 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/proxy.py @@ -0,0 +1,56 @@ +from .ssl_ import create_urllib3_context, resolve_cert_reqs, resolve_ssl_version + + +def connection_requires_http_tunnel( + proxy_url=None, proxy_config=None, destination_scheme=None +): + """ + Returns True if the connection requires an HTTP CONNECT through the proxy. + + :param URL proxy_url: + URL of the proxy. + :param ProxyConfig proxy_config: + Proxy configuration from poolmanager.py + :param str destination_scheme: + The scheme of the destination. (i.e https, http, etc) + """ + # If we're not using a proxy, no way to use a tunnel. + if proxy_url is None: + return False + + # HTTP destinations never require tunneling, we always forward. + if destination_scheme == "http": + return False + + # Support for forwarding with HTTPS proxies and HTTPS destinations. + if ( + proxy_url.scheme == "https" + and proxy_config + and proxy_config.use_forwarding_for_https + ): + return False + + # Otherwise always use a tunnel. + return True + + +def create_proxy_ssl_context( + ssl_version, cert_reqs, ca_certs=None, ca_cert_dir=None, ca_cert_data=None +): + """ + Generates a default proxy ssl context if one hasn't been provided by the + user. + """ + ssl_context = create_urllib3_context( + ssl_version=resolve_ssl_version(ssl_version), + cert_reqs=resolve_cert_reqs(cert_reqs), + ) + if ( + not ca_certs + and not ca_cert_dir + and not ca_cert_data + and hasattr(ssl_context, "load_default_certs") + ): + ssl_context.load_default_certs() + + return ssl_context diff --git a/openpype/hosts/fusion/vendor/urllib3/util/queue.py b/openpype/hosts/fusion/vendor/urllib3/util/queue.py new file mode 100644 index 0000000000..41784104ee --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/queue.py @@ -0,0 +1,22 @@ +import collections + +from ..packages import six +from ..packages.six.moves import queue + +if six.PY2: + # Queue is imported for side effects on MS Windows. See issue #229. + import Queue as _unused_module_Queue # noqa: F401 + + +class LifoQueue(queue.Queue): + def _init(self, _): + self.queue = collections.deque() + + def _qsize(self, len=len): + return len(self.queue) + + def _put(self, item): + self.queue.append(item) + + def _get(self): + return self.queue.pop() diff --git a/openpype/hosts/fusion/vendor/urllib3/util/request.py b/openpype/hosts/fusion/vendor/urllib3/util/request.py new file mode 100644 index 0000000000..25103383ec --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/request.py @@ -0,0 +1,143 @@ +from __future__ import absolute_import + +from base64 import b64encode + +from ..exceptions import UnrewindableBodyError +from ..packages.six import b, integer_types + +# Pass as a value within ``headers`` to skip +# emitting some HTTP headers that are added automatically. +# The only headers that are supported are ``Accept-Encoding``, +# ``Host``, and ``User-Agent``. +SKIP_HEADER = "@@@SKIP_HEADER@@@" +SKIPPABLE_HEADERS = frozenset(["accept-encoding", "host", "user-agent"]) + +ACCEPT_ENCODING = "gzip,deflate" +try: + import brotli as _unused_module_brotli # noqa: F401 +except ImportError: + pass +else: + ACCEPT_ENCODING += ",br" + +_FAILEDTELL = object() + + +def make_headers( + keep_alive=None, + accept_encoding=None, + user_agent=None, + basic_auth=None, + proxy_basic_auth=None, + disable_cache=None, +): + """ + Shortcuts for generating request headers. + + :param keep_alive: + If ``True``, adds 'connection: keep-alive' header. + + :param accept_encoding: + Can be a boolean, list, or string. + ``True`` translates to 'gzip,deflate'. + List will get joined by comma. + String will be used as provided. + + :param user_agent: + String representing the user-agent you want, such as + "python-urllib3/0.6" + + :param basic_auth: + Colon-separated username:password string for 'authorization: basic ...' + auth header. + + :param proxy_basic_auth: + Colon-separated username:password string for 'proxy-authorization: basic ...' + auth header. + + :param disable_cache: + If ``True``, adds 'cache-control: no-cache' header. + + Example:: + + >>> make_headers(keep_alive=True, user_agent="Batman/1.0") + {'connection': 'keep-alive', 'user-agent': 'Batman/1.0'} + >>> make_headers(accept_encoding=True) + {'accept-encoding': 'gzip,deflate'} + """ + headers = {} + if accept_encoding: + if isinstance(accept_encoding, str): + pass + elif isinstance(accept_encoding, list): + accept_encoding = ",".join(accept_encoding) + else: + accept_encoding = ACCEPT_ENCODING + headers["accept-encoding"] = accept_encoding + + if user_agent: + headers["user-agent"] = user_agent + + if keep_alive: + headers["connection"] = "keep-alive" + + if basic_auth: + headers["authorization"] = "Basic " + b64encode(b(basic_auth)).decode("utf-8") + + if proxy_basic_auth: + headers["proxy-authorization"] = "Basic " + b64encode( + b(proxy_basic_auth) + ).decode("utf-8") + + if disable_cache: + headers["cache-control"] = "no-cache" + + return headers + + +def set_file_position(body, pos): + """ + If a position is provided, move file to that point. + Otherwise, we'll attempt to record a position for future use. + """ + if pos is not None: + rewind_body(body, pos) + elif getattr(body, "tell", None) is not None: + try: + pos = body.tell() + except (IOError, OSError): + # This differentiates from None, allowing us to catch + # a failed `tell()` later when trying to rewind the body. + pos = _FAILEDTELL + + return pos + + +def rewind_body(body, body_pos): + """ + Attempt to rewind body to a certain position. + Primarily used for request redirects and retries. + + :param body: + File-like object that supports seek. + + :param int pos: + Position to seek to in file. + """ + body_seek = getattr(body, "seek", None) + if body_seek is not None and isinstance(body_pos, integer_types): + try: + body_seek(body_pos) + except (IOError, OSError): + raise UnrewindableBodyError( + "An error occurred when rewinding request body for redirect/retry." + ) + elif body_pos is _FAILEDTELL: + raise UnrewindableBodyError( + "Unable to record file position for rewinding " + "request body during a redirect/retry." + ) + else: + raise ValueError( + "body_pos must be of type integer, instead it was %s." % type(body_pos) + ) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/response.py b/openpype/hosts/fusion/vendor/urllib3/util/response.py new file mode 100644 index 0000000000..5ea609cced --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/response.py @@ -0,0 +1,107 @@ +from __future__ import absolute_import + +from email.errors import MultipartInvariantViolationDefect, StartBoundaryNotFoundDefect + +from ..exceptions import HeaderParsingError +from ..packages.six.moves import http_client as httplib + + +def is_fp_closed(obj): + """ + Checks whether a given file-like object is closed. + + :param obj: + The file-like object to check. + """ + + try: + # Check `isclosed()` first, in case Python3 doesn't set `closed`. + # GH Issue #928 + return obj.isclosed() + except AttributeError: + pass + + try: + # Check via the official file-like-object way. + return obj.closed + except AttributeError: + pass + + try: + # Check if the object is a container for another file-like object that + # gets released on exhaustion (e.g. HTTPResponse). + return obj.fp is None + except AttributeError: + pass + + raise ValueError("Unable to determine whether fp is closed.") + + +def assert_header_parsing(headers): + """ + Asserts whether all headers have been successfully parsed. + Extracts encountered errors from the result of parsing headers. + + Only works on Python 3. + + :param http.client.HTTPMessage headers: Headers to verify. + + :raises urllib3.exceptions.HeaderParsingError: + If parsing errors are found. + """ + + # This will fail silently if we pass in the wrong kind of parameter. + # To make debugging easier add an explicit check. + if not isinstance(headers, httplib.HTTPMessage): + raise TypeError("expected httplib.Message, got {0}.".format(type(headers))) + + defects = getattr(headers, "defects", None) + get_payload = getattr(headers, "get_payload", None) + + unparsed_data = None + if get_payload: + # get_payload is actually email.message.Message.get_payload; + # we're only interested in the result if it's not a multipart message + if not headers.is_multipart(): + payload = get_payload() + + if isinstance(payload, (bytes, str)): + unparsed_data = payload + if defects: + # httplib is assuming a response body is available + # when parsing headers even when httplib only sends + # header data to parse_headers() This results in + # defects on multipart responses in particular. + # See: https://github.com/urllib3/urllib3/issues/800 + + # So we ignore the following defects: + # - StartBoundaryNotFoundDefect: + # The claimed start boundary was never found. + # - MultipartInvariantViolationDefect: + # A message claimed to be a multipart but no subparts were found. + defects = [ + defect + for defect in defects + if not isinstance( + defect, (StartBoundaryNotFoundDefect, MultipartInvariantViolationDefect) + ) + ] + + if defects or unparsed_data: + raise HeaderParsingError(defects=defects, unparsed_data=unparsed_data) + + +def is_response_to_head(response): + """ + Checks whether the request of a response has been a HEAD-request. + Handles the quirks of AppEngine. + + :param http.client.HTTPResponse response: + Response to check if the originating request + used 'HEAD' as a method. + """ + # FIXME: Can we do this somehow without accessing private httplib _method? + method = response._method + if isinstance(method, int): # Platform-specific: Appengine + return method == 3 + return method.upper() == "HEAD" diff --git a/openpype/hosts/fusion/vendor/urllib3/util/retry.py b/openpype/hosts/fusion/vendor/urllib3/util/retry.py new file mode 100644 index 0000000000..c7dc42f1d6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/retry.py @@ -0,0 +1,602 @@ +from __future__ import absolute_import + +import email +import logging +import re +import time +import warnings +from collections import namedtuple +from itertools import takewhile + +from ..exceptions import ( + ConnectTimeoutError, + InvalidHeader, + MaxRetryError, + ProtocolError, + ProxyError, + ReadTimeoutError, + ResponseError, +) +from ..packages import six + +log = logging.getLogger(__name__) + + +# Data structure for representing the metadata of requests that result in a retry. +RequestHistory = namedtuple( + "RequestHistory", ["method", "url", "error", "status", "redirect_location"] +) + + +# TODO: In v2 we can remove this sentinel and metaclass with deprecated options. +_Default = object() + + +class _RetryMeta(type): + @property + def DEFAULT_METHOD_WHITELIST(cls): + warnings.warn( + "Using 'Retry.DEFAULT_METHOD_WHITELIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_ALLOWED_METHODS' instead", + DeprecationWarning, + ) + return cls.DEFAULT_ALLOWED_METHODS + + @DEFAULT_METHOD_WHITELIST.setter + def DEFAULT_METHOD_WHITELIST(cls, value): + warnings.warn( + "Using 'Retry.DEFAULT_METHOD_WHITELIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_ALLOWED_METHODS' instead", + DeprecationWarning, + ) + cls.DEFAULT_ALLOWED_METHODS = value + + @property + def DEFAULT_REDIRECT_HEADERS_BLACKLIST(cls): + warnings.warn( + "Using 'Retry.DEFAULT_REDIRECT_HEADERS_BLACKLIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_REMOVE_HEADERS_ON_REDIRECT' instead", + DeprecationWarning, + ) + return cls.DEFAULT_REMOVE_HEADERS_ON_REDIRECT + + @DEFAULT_REDIRECT_HEADERS_BLACKLIST.setter + def DEFAULT_REDIRECT_HEADERS_BLACKLIST(cls, value): + warnings.warn( + "Using 'Retry.DEFAULT_REDIRECT_HEADERS_BLACKLIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_REMOVE_HEADERS_ON_REDIRECT' instead", + DeprecationWarning, + ) + cls.DEFAULT_REMOVE_HEADERS_ON_REDIRECT = value + + +@six.add_metaclass(_RetryMeta) +class Retry(object): + """Retry configuration. + + Each retry attempt will create a new Retry object with updated values, so + they can be safely reused. + + Retries can be defined as a default for a pool:: + + retries = Retry(connect=5, read=2, redirect=5) + http = PoolManager(retries=retries) + response = http.request('GET', 'http://example.com/') + + Or per-request (which overrides the default for the pool):: + + response = http.request('GET', 'http://example.com/', retries=Retry(10)) + + Retries can be disabled by passing ``False``:: + + response = http.request('GET', 'http://example.com/', retries=False) + + Errors will be wrapped in :class:`~urllib3.exceptions.MaxRetryError` unless + retries are disabled, in which case the causing exception will be raised. + + :param int total: + Total number of retries to allow. Takes precedence over other counts. + + Set to ``None`` to remove this constraint and fall back on other + counts. + + Set to ``0`` to fail on the first retry. + + Set to ``False`` to disable and imply ``raise_on_redirect=False``. + + :param int connect: + How many connection-related errors to retry on. + + These are errors raised before the request is sent to the remote server, + which we assume has not triggered the server to process the request. + + Set to ``0`` to fail on the first retry of this type. + + :param int read: + How many times to retry on read errors. + + These errors are raised after the request was sent to the server, so the + request may have side-effects. + + Set to ``0`` to fail on the first retry of this type. + + :param int redirect: + How many redirects to perform. Limit this to avoid infinite redirect + loops. + + A redirect is a HTTP response with a status code 301, 302, 303, 307 or + 308. + + Set to ``0`` to fail on the first retry of this type. + + Set to ``False`` to disable and imply ``raise_on_redirect=False``. + + :param int status: + How many times to retry on bad status codes. + + These are retries made on responses, where status code matches + ``status_forcelist``. + + Set to ``0`` to fail on the first retry of this type. + + :param int other: + How many times to retry on other errors. + + Other errors are errors that are not connect, read, redirect or status errors. + These errors might be raised after the request was sent to the server, so the + request might have side-effects. + + Set to ``0`` to fail on the first retry of this type. + + If ``total`` is not set, it's a good idea to set this to 0 to account + for unexpected edge cases and avoid infinite retry loops. + + :param iterable allowed_methods: + Set of uppercased HTTP method verbs that we should retry on. + + By default, we only retry on methods which are considered to be + idempotent (multiple requests with the same parameters end with the + same state). See :attr:`Retry.DEFAULT_ALLOWED_METHODS`. + + Set to a ``False`` value to retry on any verb. + + .. warning:: + + Previously this parameter was named ``method_whitelist``, that + usage is deprecated in v1.26.0 and will be removed in v2.0. + + :param iterable status_forcelist: + A set of integer HTTP status codes that we should force a retry on. + A retry is initiated if the request method is in ``allowed_methods`` + and the response status code is in ``status_forcelist``. + + By default, this is disabled with ``None``. + + :param float backoff_factor: + A backoff factor to apply between attempts after the second try + (most errors are resolved immediately by a second try without a + delay). urllib3 will sleep for:: + + {backoff factor} * (2 ** ({number of total retries} - 1)) + + seconds. If the backoff_factor is 0.1, then :func:`.sleep` will sleep + for [0.0s, 0.2s, 0.4s, ...] between retries. It will never be longer + than :attr:`Retry.BACKOFF_MAX`. + + By default, backoff is disabled (set to 0). + + :param bool raise_on_redirect: Whether, if the number of redirects is + exhausted, to raise a MaxRetryError, or to return a response with a + response code in the 3xx range. + + :param bool raise_on_status: Similar meaning to ``raise_on_redirect``: + whether we should raise an exception, or return a response, + if status falls in ``status_forcelist`` range and retries have + been exhausted. + + :param tuple history: The history of the request encountered during + each call to :meth:`~Retry.increment`. The list is in the order + the requests occurred. Each list item is of class :class:`RequestHistory`. + + :param bool respect_retry_after_header: + Whether to respect Retry-After header on status codes defined as + :attr:`Retry.RETRY_AFTER_STATUS_CODES` or not. + + :param iterable remove_headers_on_redirect: + Sequence of headers to remove from the request when a response + indicating a redirect is returned before firing off the redirected + request. + """ + + #: Default methods to be used for ``allowed_methods`` + DEFAULT_ALLOWED_METHODS = frozenset( + ["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"] + ) + + #: Default status codes to be used for ``status_forcelist`` + RETRY_AFTER_STATUS_CODES = frozenset([413, 429, 503]) + + #: Default headers to be used for ``remove_headers_on_redirect`` + DEFAULT_REMOVE_HEADERS_ON_REDIRECT = frozenset(["Authorization"]) + + #: Maximum backoff time. + BACKOFF_MAX = 120 + + def __init__( + self, + total=10, + connect=None, + read=None, + redirect=None, + status=None, + other=None, + allowed_methods=_Default, + status_forcelist=None, + backoff_factor=0, + raise_on_redirect=True, + raise_on_status=True, + history=None, + respect_retry_after_header=True, + remove_headers_on_redirect=_Default, + # TODO: Deprecated, remove in v2.0 + method_whitelist=_Default, + ): + + if method_whitelist is not _Default: + if allowed_methods is not _Default: + raise ValueError( + "Using both 'allowed_methods' and " + "'method_whitelist' together is not allowed. " + "Instead only use 'allowed_methods'" + ) + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + stacklevel=2, + ) + allowed_methods = method_whitelist + if allowed_methods is _Default: + allowed_methods = self.DEFAULT_ALLOWED_METHODS + if remove_headers_on_redirect is _Default: + remove_headers_on_redirect = self.DEFAULT_REMOVE_HEADERS_ON_REDIRECT + + self.total = total + self.connect = connect + self.read = read + self.status = status + self.other = other + + if redirect is False or total is False: + redirect = 0 + raise_on_redirect = False + + self.redirect = redirect + self.status_forcelist = status_forcelist or set() + self.allowed_methods = allowed_methods + self.backoff_factor = backoff_factor + self.raise_on_redirect = raise_on_redirect + self.raise_on_status = raise_on_status + self.history = history or tuple() + self.respect_retry_after_header = respect_retry_after_header + self.remove_headers_on_redirect = frozenset( + [h.lower() for h in remove_headers_on_redirect] + ) + + def new(self, **kw): + params = dict( + total=self.total, + connect=self.connect, + read=self.read, + redirect=self.redirect, + status=self.status, + other=self.other, + status_forcelist=self.status_forcelist, + backoff_factor=self.backoff_factor, + raise_on_redirect=self.raise_on_redirect, + raise_on_status=self.raise_on_status, + history=self.history, + remove_headers_on_redirect=self.remove_headers_on_redirect, + respect_retry_after_header=self.respect_retry_after_header, + ) + + # TODO: If already given in **kw we use what's given to us + # If not given we need to figure out what to pass. We decide + # based on whether our class has the 'method_whitelist' property + # and if so we pass the deprecated 'method_whitelist' otherwise + # we use 'allowed_methods'. Remove in v2.0 + if "method_whitelist" not in kw and "allowed_methods" not in kw: + if "method_whitelist" in self.__dict__: + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + ) + params["method_whitelist"] = self.allowed_methods + else: + params["allowed_methods"] = self.allowed_methods + + params.update(kw) + return type(self)(**params) + + @classmethod + def from_int(cls, retries, redirect=True, default=None): + """Backwards-compatibility for the old retries format.""" + if retries is None: + retries = default if default is not None else cls.DEFAULT + + if isinstance(retries, Retry): + return retries + + redirect = bool(redirect) and None + new_retries = cls(retries, redirect=redirect) + log.debug("Converted retries value: %r -> %r", retries, new_retries) + return new_retries + + def get_backoff_time(self): + """Formula for computing the current backoff + + :rtype: float + """ + # We want to consider only the last consecutive errors sequence (Ignore redirects). + consecutive_errors_len = len( + list( + takewhile(lambda x: x.redirect_location is None, reversed(self.history)) + ) + ) + if consecutive_errors_len <= 1: + return 0 + + backoff_value = self.backoff_factor * (2 ** (consecutive_errors_len - 1)) + return min(self.BACKOFF_MAX, backoff_value) + + def parse_retry_after(self, retry_after): + # Whitespace: https://tools.ietf.org/html/rfc7230#section-3.2.4 + if re.match(r"^\s*[0-9]+\s*$", retry_after): + seconds = int(retry_after) + else: + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + raise InvalidHeader("Invalid Retry-After header: %s" % retry_after) + if retry_date_tuple[9] is None: # Python 2 + # Assume UTC if no timezone was specified + # On Python2.7, parsedate_tz returns None for a timezone offset + # instead of 0 if no timezone is given, where mktime_tz treats + # a None timezone offset as local time. + retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:] + + retry_date = email.utils.mktime_tz(retry_date_tuple) + seconds = retry_date - time.time() + + if seconds < 0: + seconds = 0 + + return seconds + + def get_retry_after(self, response): + """Get the value of Retry-After in seconds.""" + + retry_after = response.getheader("Retry-After") + + if retry_after is None: + return None + + return self.parse_retry_after(retry_after) + + def sleep_for_retry(self, response=None): + retry_after = self.get_retry_after(response) + if retry_after: + time.sleep(retry_after) + return True + + return False + + def _sleep_backoff(self): + backoff = self.get_backoff_time() + if backoff <= 0: + return + time.sleep(backoff) + + def sleep(self, response=None): + """Sleep between retry attempts. + + This method will respect a server's ``Retry-After`` response header + and sleep the duration of the time requested. If that is not present, it + will use an exponential backoff. By default, the backoff factor is 0 and + this method will return immediately. + """ + + if self.respect_retry_after_header and response: + slept = self.sleep_for_retry(response) + if slept: + return + + self._sleep_backoff() + + def _is_connection_error(self, err): + """Errors when we're fairly sure that the server did not receive the + request, so it should be safe to retry. + """ + if isinstance(err, ProxyError): + err = err.original_error + return isinstance(err, ConnectTimeoutError) + + def _is_read_error(self, err): + """Errors that occur after the request has been started, so we should + assume that the server began processing it. + """ + return isinstance(err, (ReadTimeoutError, ProtocolError)) + + def _is_method_retryable(self, method): + """Checks if a given HTTP method should be retried upon, depending if + it is included in the allowed_methods + """ + # TODO: For now favor if the Retry implementation sets its own method_whitelist + # property outside of our constructor to avoid breaking custom implementations. + if "method_whitelist" in self.__dict__: + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + ) + allowed_methods = self.method_whitelist + else: + allowed_methods = self.allowed_methods + + if allowed_methods and method.upper() not in allowed_methods: + return False + return True + + def is_retry(self, method, status_code, has_retry_after=False): + """Is this method/status code retryable? (Based on allowlists and control + variables such as the number of total retries to allow, whether to + respect the Retry-After header, whether this header is present, and + whether the returned status code is on the list of status codes to + be retried upon on the presence of the aforementioned header) + """ + if not self._is_method_retryable(method): + return False + + if self.status_forcelist and status_code in self.status_forcelist: + return True + + return ( + self.total + and self.respect_retry_after_header + and has_retry_after + and (status_code in self.RETRY_AFTER_STATUS_CODES) + ) + + def is_exhausted(self): + """Are we out of retries?""" + retry_counts = ( + self.total, + self.connect, + self.read, + self.redirect, + self.status, + self.other, + ) + retry_counts = list(filter(None, retry_counts)) + if not retry_counts: + return False + + return min(retry_counts) < 0 + + def increment( + self, + method=None, + url=None, + response=None, + error=None, + _pool=None, + _stacktrace=None, + ): + """Return a new Retry object with incremented retry counters. + + :param response: A response object, or None, if the server did not + return a response. + :type response: :class:`~urllib3.response.HTTPResponse` + :param Exception error: An error encountered during the request, or + None if the response was received successfully. + + :return: A new ``Retry`` object. + """ + if self.total is False and error: + # Disabled, indicate to re-raise the error. + raise six.reraise(type(error), error, _stacktrace) + + total = self.total + if total is not None: + total -= 1 + + connect = self.connect + read = self.read + redirect = self.redirect + status_count = self.status + other = self.other + cause = "unknown" + status = None + redirect_location = None + + if error and self._is_connection_error(error): + # Connect retry? + if connect is False: + raise six.reraise(type(error), error, _stacktrace) + elif connect is not None: + connect -= 1 + + elif error and self._is_read_error(error): + # Read retry? + if read is False or not self._is_method_retryable(method): + raise six.reraise(type(error), error, _stacktrace) + elif read is not None: + read -= 1 + + elif error: + # Other retry? + if other is not None: + other -= 1 + + elif response and response.get_redirect_location(): + # Redirect retry? + if redirect is not None: + redirect -= 1 + cause = "too many redirects" + redirect_location = response.get_redirect_location() + status = response.status + + else: + # Incrementing because of a server error like a 500 in + # status_forcelist and the given method is in the allowed_methods + cause = ResponseError.GENERIC_ERROR + if response and response.status: + if status_count is not None: + status_count -= 1 + cause = ResponseError.SPECIFIC_ERROR.format(status_code=response.status) + status = response.status + + history = self.history + ( + RequestHistory(method, url, error, status, redirect_location), + ) + + new_retry = self.new( + total=total, + connect=connect, + read=read, + redirect=redirect, + status=status_count, + other=other, + history=history, + ) + + if new_retry.is_exhausted(): + raise MaxRetryError(_pool, url, error or ResponseError(cause)) + + log.debug("Incremented Retry for (url='%s'): %r", url, new_retry) + + return new_retry + + def __repr__(self): + return ( + "{cls.__name__}(total={self.total}, connect={self.connect}, " + "read={self.read}, redirect={self.redirect}, status={self.status})" + ).format(cls=type(self), self=self) + + def __getattr__(self, item): + if item == "method_whitelist": + # TODO: Remove this deprecated alias in v2.0 + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + ) + return self.allowed_methods + try: + return getattr(super(Retry, self), item) + except AttributeError: + return getattr(Retry, item) + + +# For backwards compatibility (equivalent to pre-v1.9): +Retry.DEFAULT = Retry(3) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/ssl_.py b/openpype/hosts/fusion/vendor/urllib3/util/ssl_.py new file mode 100644 index 0000000000..8f867812a5 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/ssl_.py @@ -0,0 +1,495 @@ +from __future__ import absolute_import + +import hmac +import os +import sys +import warnings +from binascii import hexlify, unhexlify +from hashlib import md5, sha1, sha256 + +from ..exceptions import ( + InsecurePlatformWarning, + ProxySchemeUnsupported, + SNIMissingWarning, + SSLError, +) +from ..packages import six +from .url import BRACELESS_IPV6_ADDRZ_RE, IPV4_RE + +SSLContext = None +SSLTransport = None +HAS_SNI = False +IS_PYOPENSSL = False +IS_SECURETRANSPORT = False +ALPN_PROTOCOLS = ["http/1.1"] + +# Maps the length of a digest to a possible hash function producing this digest +HASHFUNC_MAP = {32: md5, 40: sha1, 64: sha256} + + +def _const_compare_digest_backport(a, b): + """ + Compare two digests of equal length in constant time. + + The digests must be of type str/bytes. + Returns True if the digests match, and False otherwise. + """ + result = abs(len(a) - len(b)) + for left, right in zip(bytearray(a), bytearray(b)): + result |= left ^ right + return result == 0 + + +_const_compare_digest = getattr(hmac, "compare_digest", _const_compare_digest_backport) + +try: # Test for SSL features + import ssl + from ssl import CERT_REQUIRED, wrap_socket +except ImportError: + pass + +try: + from ssl import HAS_SNI # Has SNI? +except ImportError: + pass + +try: + from .ssltransport import SSLTransport +except ImportError: + pass + + +try: # Platform-specific: Python 3.6 + from ssl import PROTOCOL_TLS + + PROTOCOL_SSLv23 = PROTOCOL_TLS +except ImportError: + try: + from ssl import PROTOCOL_SSLv23 as PROTOCOL_TLS + + PROTOCOL_SSLv23 = PROTOCOL_TLS + except ImportError: + PROTOCOL_SSLv23 = PROTOCOL_TLS = 2 + +try: + from ssl import PROTOCOL_TLS_CLIENT +except ImportError: + PROTOCOL_TLS_CLIENT = PROTOCOL_TLS + + +try: + from ssl import OP_NO_COMPRESSION, OP_NO_SSLv2, OP_NO_SSLv3 +except ImportError: + OP_NO_SSLv2, OP_NO_SSLv3 = 0x1000000, 0x2000000 + OP_NO_COMPRESSION = 0x20000 + + +try: # OP_NO_TICKET was added in Python 3.6 + from ssl import OP_NO_TICKET +except ImportError: + OP_NO_TICKET = 0x4000 + + +# A secure default. +# Sources for more information on TLS ciphers: +# +# - https://wiki.mozilla.org/Security/Server_Side_TLS +# - https://www.ssllabs.com/projects/best-practices/index.html +# - https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/ +# +# The general intent is: +# - prefer cipher suites that offer perfect forward secrecy (DHE/ECDHE), +# - prefer ECDHE over DHE for better performance, +# - prefer any AES-GCM and ChaCha20 over any AES-CBC for better performance and +# security, +# - prefer AES-GCM over ChaCha20 because hardware-accelerated AES is common, +# - disable NULL authentication, MD5 MACs, DSS, and other +# insecure ciphers for security reasons. +# - NOTE: TLS 1.3 cipher suites are managed through a different interface +# not exposed by CPython (yet!) and are enabled by default if they're available. +DEFAULT_CIPHERS = ":".join( + [ + "ECDHE+AESGCM", + "ECDHE+CHACHA20", + "DHE+AESGCM", + "DHE+CHACHA20", + "ECDH+AESGCM", + "DH+AESGCM", + "ECDH+AES", + "DH+AES", + "RSA+AESGCM", + "RSA+AES", + "!aNULL", + "!eNULL", + "!MD5", + "!DSS", + ] +) + +try: + from ssl import SSLContext # Modern SSL? +except ImportError: + + class SSLContext(object): # Platform-specific: Python 2 + def __init__(self, protocol_version): + self.protocol = protocol_version + # Use default values from a real SSLContext + self.check_hostname = False + self.verify_mode = ssl.CERT_NONE + self.ca_certs = None + self.options = 0 + self.certfile = None + self.keyfile = None + self.ciphers = None + + def load_cert_chain(self, certfile, keyfile): + self.certfile = certfile + self.keyfile = keyfile + + def load_verify_locations(self, cafile=None, capath=None, cadata=None): + self.ca_certs = cafile + + if capath is not None: + raise SSLError("CA directories not supported in older Pythons") + + if cadata is not None: + raise SSLError("CA data not supported in older Pythons") + + def set_ciphers(self, cipher_suite): + self.ciphers = cipher_suite + + def wrap_socket(self, socket, server_hostname=None, server_side=False): + warnings.warn( + "A true SSLContext object is not available. This prevents " + "urllib3 from configuring SSL appropriately and may cause " + "certain SSL connections to fail. You can upgrade to a newer " + "version of Python to solve this. For more information, see " + "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" + "#ssl-warnings", + InsecurePlatformWarning, + ) + kwargs = { + "keyfile": self.keyfile, + "certfile": self.certfile, + "ca_certs": self.ca_certs, + "cert_reqs": self.verify_mode, + "ssl_version": self.protocol, + "server_side": server_side, + } + return wrap_socket(socket, ciphers=self.ciphers, **kwargs) + + +def assert_fingerprint(cert, fingerprint): + """ + Checks if given fingerprint matches the supplied certificate. + + :param cert: + Certificate as bytes object. + :param fingerprint: + Fingerprint as string of hexdigits, can be interspersed by colons. + """ + + fingerprint = fingerprint.replace(":", "").lower() + digest_length = len(fingerprint) + hashfunc = HASHFUNC_MAP.get(digest_length) + if not hashfunc: + raise SSLError("Fingerprint of invalid length: {0}".format(fingerprint)) + + # We need encode() here for py32; works on py2 and p33. + fingerprint_bytes = unhexlify(fingerprint.encode()) + + cert_digest = hashfunc(cert).digest() + + if not _const_compare_digest(cert_digest, fingerprint_bytes): + raise SSLError( + 'Fingerprints did not match. Expected "{0}", got "{1}".'.format( + fingerprint, hexlify(cert_digest) + ) + ) + + +def resolve_cert_reqs(candidate): + """ + Resolves the argument to a numeric constant, which can be passed to + the wrap_socket function/method from the ssl module. + Defaults to :data:`ssl.CERT_REQUIRED`. + If given a string it is assumed to be the name of the constant in the + :mod:`ssl` module or its abbreviation. + (So you can specify `REQUIRED` instead of `CERT_REQUIRED`. + If it's neither `None` nor a string we assume it is already the numeric + constant which can directly be passed to wrap_socket. + """ + if candidate is None: + return CERT_REQUIRED + + if isinstance(candidate, str): + res = getattr(ssl, candidate, None) + if res is None: + res = getattr(ssl, "CERT_" + candidate) + return res + + return candidate + + +def resolve_ssl_version(candidate): + """ + like resolve_cert_reqs + """ + if candidate is None: + return PROTOCOL_TLS + + if isinstance(candidate, str): + res = getattr(ssl, candidate, None) + if res is None: + res = getattr(ssl, "PROTOCOL_" + candidate) + return res + + return candidate + + +def create_urllib3_context( + ssl_version=None, cert_reqs=None, options=None, ciphers=None +): + """All arguments have the same meaning as ``ssl_wrap_socket``. + + By default, this function does a lot of the same work that + ``ssl.create_default_context`` does on Python 3.4+. It: + + - Disables SSLv2, SSLv3, and compression + - Sets a restricted set of server ciphers + + If you wish to enable SSLv3, you can do:: + + from urllib3.util import ssl_ + context = ssl_.create_urllib3_context() + context.options &= ~ssl_.OP_NO_SSLv3 + + You can do the same to enable compression (substituting ``COMPRESSION`` + for ``SSLv3`` in the last line above). + + :param ssl_version: + The desired protocol version to use. This will default to + PROTOCOL_SSLv23 which will negotiate the highest protocol that both + the server and your installation of OpenSSL support. + :param cert_reqs: + Whether to require the certificate verification. This defaults to + ``ssl.CERT_REQUIRED``. + :param options: + Specific OpenSSL options. These default to ``ssl.OP_NO_SSLv2``, + ``ssl.OP_NO_SSLv3``, ``ssl.OP_NO_COMPRESSION``, and ``ssl.OP_NO_TICKET``. + :param ciphers: + Which cipher suites to allow the server to select. + :returns: + Constructed SSLContext object with specified options + :rtype: SSLContext + """ + # PROTOCOL_TLS is deprecated in Python 3.10 + if not ssl_version or ssl_version == PROTOCOL_TLS: + ssl_version = PROTOCOL_TLS_CLIENT + + context = SSLContext(ssl_version) + + context.set_ciphers(ciphers or DEFAULT_CIPHERS) + + # Setting the default here, as we may have no ssl module on import + cert_reqs = ssl.CERT_REQUIRED if cert_reqs is None else cert_reqs + + if options is None: + options = 0 + # SSLv2 is easily broken and is considered harmful and dangerous + options |= OP_NO_SSLv2 + # SSLv3 has several problems and is now dangerous + options |= OP_NO_SSLv3 + # Disable compression to prevent CRIME attacks for OpenSSL 1.0+ + # (issue #309) + options |= OP_NO_COMPRESSION + # TLSv1.2 only. Unless set explicitly, do not request tickets. + # This may save some bandwidth on wire, and although the ticket is encrypted, + # there is a risk associated with it being on wire, + # if the server is not rotating its ticketing keys properly. + options |= OP_NO_TICKET + + context.options |= options + + # Enable post-handshake authentication for TLS 1.3, see GH #1634. PHA is + # necessary for conditional client cert authentication with TLS 1.3. + # The attribute is None for OpenSSL <= 1.1.0 or does not exist in older + # versions of Python. We only enable on Python 3.7.4+ or if certificate + # verification is enabled to work around Python issue #37428 + # See: https://bugs.python.org/issue37428 + if (cert_reqs == ssl.CERT_REQUIRED or sys.version_info >= (3, 7, 4)) and getattr( + context, "post_handshake_auth", None + ) is not None: + context.post_handshake_auth = True + + def disable_check_hostname(): + if ( + getattr(context, "check_hostname", None) is not None + ): # Platform-specific: Python 3.2 + # We do our own verification, including fingerprints and alternative + # hostnames. So disable it here + context.check_hostname = False + + # The order of the below lines setting verify_mode and check_hostname + # matter due to safe-guards SSLContext has to prevent an SSLContext with + # check_hostname=True, verify_mode=NONE/OPTIONAL. This is made even more + # complex because we don't know whether PROTOCOL_TLS_CLIENT will be used + # or not so we don't know the initial state of the freshly created SSLContext. + if cert_reqs == ssl.CERT_REQUIRED: + context.verify_mode = cert_reqs + disable_check_hostname() + else: + disable_check_hostname() + context.verify_mode = cert_reqs + + # Enable logging of TLS session keys via defacto standard environment variable + # 'SSLKEYLOGFILE', if the feature is available (Python 3.8+). Skip empty values. + if hasattr(context, "keylog_filename"): + sslkeylogfile = os.environ.get("SSLKEYLOGFILE") + if sslkeylogfile: + context.keylog_filename = sslkeylogfile + + return context + + +def ssl_wrap_socket( + sock, + keyfile=None, + certfile=None, + cert_reqs=None, + ca_certs=None, + server_hostname=None, + ssl_version=None, + ciphers=None, + ssl_context=None, + ca_cert_dir=None, + key_password=None, + ca_cert_data=None, + tls_in_tls=False, +): + """ + All arguments except for server_hostname, ssl_context, and ca_cert_dir have + the same meaning as they do when using :func:`ssl.wrap_socket`. + + :param server_hostname: + When SNI is supported, the expected hostname of the certificate + :param ssl_context: + A pre-made :class:`SSLContext` object. If none is provided, one will + be created using :func:`create_urllib3_context`. + :param ciphers: + A string of ciphers we wish the client to support. + :param ca_cert_dir: + A directory containing CA certificates in multiple separate files, as + supported by OpenSSL's -CApath flag or the capath argument to + SSLContext.load_verify_locations(). + :param key_password: + Optional password if the keyfile is encrypted. + :param ca_cert_data: + Optional string containing CA certificates in PEM format suitable for + passing as the cadata parameter to SSLContext.load_verify_locations() + :param tls_in_tls: + Use SSLTransport to wrap the existing socket. + """ + context = ssl_context + if context is None: + # Note: This branch of code and all the variables in it are no longer + # used by urllib3 itself. We should consider deprecating and removing + # this code. + context = create_urllib3_context(ssl_version, cert_reqs, ciphers=ciphers) + + if ca_certs or ca_cert_dir or ca_cert_data: + try: + context.load_verify_locations(ca_certs, ca_cert_dir, ca_cert_data) + except (IOError, OSError) as e: + raise SSLError(e) + + elif ssl_context is None and hasattr(context, "load_default_certs"): + # try to load OS default certs; works well on Windows (require Python3.4+) + context.load_default_certs() + + # Attempt to detect if we get the goofy behavior of the + # keyfile being encrypted and OpenSSL asking for the + # passphrase via the terminal and instead error out. + if keyfile and key_password is None and _is_key_file_encrypted(keyfile): + raise SSLError("Client private key is encrypted, password is required") + + if certfile: + if key_password is None: + context.load_cert_chain(certfile, keyfile) + else: + context.load_cert_chain(certfile, keyfile, key_password) + + try: + if hasattr(context, "set_alpn_protocols"): + context.set_alpn_protocols(ALPN_PROTOCOLS) + except NotImplementedError: # Defensive: in CI, we always have set_alpn_protocols + pass + + # If we detect server_hostname is an IP address then the SNI + # extension should not be used according to RFC3546 Section 3.1 + use_sni_hostname = server_hostname and not is_ipaddress(server_hostname) + # SecureTransport uses server_hostname in certificate verification. + send_sni = (use_sni_hostname and HAS_SNI) or ( + IS_SECURETRANSPORT and server_hostname + ) + # Do not warn the user if server_hostname is an invalid SNI hostname. + if not HAS_SNI and use_sni_hostname: + warnings.warn( + "An HTTPS request has been made, but the SNI (Server Name " + "Indication) extension to TLS is not available on this platform. " + "This may cause the server to present an incorrect TLS " + "certificate, which can cause validation failures. You can upgrade to " + "a newer version of Python to solve this. For more information, see " + "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" + "#ssl-warnings", + SNIMissingWarning, + ) + + if send_sni: + ssl_sock = _ssl_wrap_socket_impl( + sock, context, tls_in_tls, server_hostname=server_hostname + ) + else: + ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls) + return ssl_sock + + +def is_ipaddress(hostname): + """Detects whether the hostname given is an IPv4 or IPv6 address. + Also detects IPv6 addresses with Zone IDs. + + :param str hostname: Hostname to examine. + :return: True if the hostname is an IP address, False otherwise. + """ + if not six.PY2 and isinstance(hostname, bytes): + # IDN A-label bytes are ASCII compatible. + hostname = hostname.decode("ascii") + return bool(IPV4_RE.match(hostname) or BRACELESS_IPV6_ADDRZ_RE.match(hostname)) + + +def _is_key_file_encrypted(key_file): + """Detects if a key file is encrypted or not.""" + with open(key_file, "r") as f: + for line in f: + # Look for Proc-Type: 4,ENCRYPTED + if "ENCRYPTED" in line: + return True + + return False + + +def _ssl_wrap_socket_impl(sock, ssl_context, tls_in_tls, server_hostname=None): + if tls_in_tls: + if not SSLTransport: + # Import error, ssl is not available. + raise ProxySchemeUnsupported( + "TLS in TLS requires support for the 'ssl' module" + ) + + SSLTransport._validate_ssl_context_for_tls_in_tls(ssl_context) + return SSLTransport(sock, ssl_context, server_hostname) + + if server_hostname: + return ssl_context.wrap_socket(sock, server_hostname=server_hostname) + else: + return ssl_context.wrap_socket(sock) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py b/openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py new file mode 100644 index 0000000000..c2186bced9 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py @@ -0,0 +1,221 @@ +import io +import socket +import ssl + +from urllib3.exceptions import ProxySchemeUnsupported +from urllib3.packages import six + +SSL_BLOCKSIZE = 16384 + + +class SSLTransport: + """ + The SSLTransport wraps an existing socket and establishes an SSL connection. + + Contrary to Python's implementation of SSLSocket, it allows you to chain + multiple TLS connections together. It's particularly useful if you need to + implement TLS within TLS. + + The class supports most of the socket API operations. + """ + + @staticmethod + def _validate_ssl_context_for_tls_in_tls(ssl_context): + """ + Raises a ProxySchemeUnsupported if the provided ssl_context can't be used + for TLS in TLS. + + The only requirement is that the ssl_context provides the 'wrap_bio' + methods. + """ + + if not hasattr(ssl_context, "wrap_bio"): + if six.PY2: + raise ProxySchemeUnsupported( + "TLS in TLS requires SSLContext.wrap_bio() which isn't " + "supported on Python 2" + ) + else: + raise ProxySchemeUnsupported( + "TLS in TLS requires SSLContext.wrap_bio() which isn't " + "available on non-native SSLContext" + ) + + def __init__( + self, socket, ssl_context, server_hostname=None, suppress_ragged_eofs=True + ): + """ + Create an SSLTransport around socket using the provided ssl_context. + """ + self.incoming = ssl.MemoryBIO() + self.outgoing = ssl.MemoryBIO() + + self.suppress_ragged_eofs = suppress_ragged_eofs + self.socket = socket + + self.sslobj = ssl_context.wrap_bio( + self.incoming, self.outgoing, server_hostname=server_hostname + ) + + # Perform initial handshake. + self._ssl_io_loop(self.sslobj.do_handshake) + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + def fileno(self): + return self.socket.fileno() + + def read(self, len=1024, buffer=None): + return self._wrap_ssl_read(len, buffer) + + def recv(self, len=1024, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to recv") + return self._wrap_ssl_read(len) + + def recv_into(self, buffer, nbytes=None, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to recv_into") + if buffer and (nbytes is None): + nbytes = len(buffer) + elif nbytes is None: + nbytes = 1024 + return self.read(nbytes, buffer) + + def sendall(self, data, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to sendall") + count = 0 + with memoryview(data) as view, view.cast("B") as byte_view: + amount = len(byte_view) + while count < amount: + v = self.send(byte_view[count:]) + count += v + + def send(self, data, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to send") + response = self._ssl_io_loop(self.sslobj.write, data) + return response + + def makefile( + self, mode="r", buffering=None, encoding=None, errors=None, newline=None + ): + """ + Python's httpclient uses makefile and buffered io when reading HTTP + messages and we need to support it. + + This is unfortunately a copy and paste of socket.py makefile with small + changes to point to the socket directly. + """ + if not set(mode) <= {"r", "w", "b"}: + raise ValueError("invalid mode %r (only r, w, b allowed)" % (mode,)) + + writing = "w" in mode + reading = "r" in mode or not writing + assert reading or writing + binary = "b" in mode + rawmode = "" + if reading: + rawmode += "r" + if writing: + rawmode += "w" + raw = socket.SocketIO(self, rawmode) + self.socket._io_refs += 1 + if buffering is None: + buffering = -1 + if buffering < 0: + buffering = io.DEFAULT_BUFFER_SIZE + if buffering == 0: + if not binary: + raise ValueError("unbuffered streams must be binary") + return raw + if reading and writing: + buffer = io.BufferedRWPair(raw, raw, buffering) + elif reading: + buffer = io.BufferedReader(raw, buffering) + else: + assert writing + buffer = io.BufferedWriter(raw, buffering) + if binary: + return buffer + text = io.TextIOWrapper(buffer, encoding, errors, newline) + text.mode = mode + return text + + def unwrap(self): + self._ssl_io_loop(self.sslobj.unwrap) + + def close(self): + self.socket.close() + + def getpeercert(self, binary_form=False): + return self.sslobj.getpeercert(binary_form) + + def version(self): + return self.sslobj.version() + + def cipher(self): + return self.sslobj.cipher() + + def selected_alpn_protocol(self): + return self.sslobj.selected_alpn_protocol() + + def selected_npn_protocol(self): + return self.sslobj.selected_npn_protocol() + + def shared_ciphers(self): + return self.sslobj.shared_ciphers() + + def compression(self): + return self.sslobj.compression() + + def settimeout(self, value): + self.socket.settimeout(value) + + def gettimeout(self): + return self.socket.gettimeout() + + def _decref_socketios(self): + self.socket._decref_socketios() + + def _wrap_ssl_read(self, len, buffer=None): + try: + return self._ssl_io_loop(self.sslobj.read, len, buffer) + except ssl.SSLError as e: + if e.errno == ssl.SSL_ERROR_EOF and self.suppress_ragged_eofs: + return 0 # eof, return 0. + else: + raise + + def _ssl_io_loop(self, func, *args): + """Performs an I/O loop between incoming/outgoing and the socket.""" + should_loop = True + ret = None + + while should_loop: + errno = None + try: + ret = func(*args) + except ssl.SSLError as e: + if e.errno not in (ssl.SSL_ERROR_WANT_READ, ssl.SSL_ERROR_WANT_WRITE): + # WANT_READ, and WANT_WRITE are expected, others are not. + raise e + errno = e.errno + + buf = self.outgoing.read() + self.socket.sendall(buf) + + if errno is None: + should_loop = False + elif errno == ssl.SSL_ERROR_WANT_READ: + buf = self.socket.recv(SSL_BLOCKSIZE) + if buf: + self.incoming.write(buf) + else: + self.incoming.write_eof() + return ret diff --git a/openpype/hosts/fusion/vendor/urllib3/util/timeout.py b/openpype/hosts/fusion/vendor/urllib3/util/timeout.py new file mode 100644 index 0000000000..ff69593b05 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/timeout.py @@ -0,0 +1,268 @@ +from __future__ import absolute_import + +import time + +# The default socket timeout, used by httplib to indicate that no timeout was +# specified by the user +from socket import _GLOBAL_DEFAULT_TIMEOUT + +from ..exceptions import TimeoutStateError + +# A sentinel value to indicate that no timeout was specified by the user in +# urllib3 +_Default = object() + + +# Use time.monotonic if available. +current_time = getattr(time, "monotonic", time.time) + + +class Timeout(object): + """Timeout configuration. + + Timeouts can be defined as a default for a pool: + + .. code-block:: python + + timeout = Timeout(connect=2.0, read=7.0) + http = PoolManager(timeout=timeout) + response = http.request('GET', 'http://example.com/') + + Or per-request (which overrides the default for the pool): + + .. code-block:: python + + response = http.request('GET', 'http://example.com/', timeout=Timeout(10)) + + Timeouts can be disabled by setting all the parameters to ``None``: + + .. code-block:: python + + no_timeout = Timeout(connect=None, read=None) + response = http.request('GET', 'http://example.com/, timeout=no_timeout) + + + :param total: + This combines the connect and read timeouts into one; the read timeout + will be set to the time leftover from the connect attempt. In the + event that both a connect timeout and a total are specified, or a read + timeout and a total are specified, the shorter timeout will be applied. + + Defaults to None. + + :type total: int, float, or None + + :param connect: + The maximum amount of time (in seconds) to wait for a connection + attempt to a server to succeed. Omitting the parameter will default the + connect timeout to the system default, probably `the global default + timeout in socket.py + `_. + None will set an infinite timeout for connection attempts. + + :type connect: int, float, or None + + :param read: + The maximum amount of time (in seconds) to wait between consecutive + read operations for a response from the server. Omitting the parameter + will default the read timeout to the system default, probably `the + global default timeout in socket.py + `_. + None will set an infinite timeout. + + :type read: int, float, or None + + .. note:: + + Many factors can affect the total amount of time for urllib3 to return + an HTTP response. + + For example, Python's DNS resolver does not obey the timeout specified + on the socket. Other factors that can affect total request time include + high CPU load, high swap, the program running at a low priority level, + or other behaviors. + + In addition, the read and total timeouts only measure the time between + read operations on the socket connecting the client and the server, + not the total amount of time for the request to return a complete + response. For most requests, the timeout is raised because the server + has not sent the first byte in the specified time. This is not always + the case; if a server streams one byte every fifteen seconds, a timeout + of 20 seconds will not trigger, even though the request will take + several minutes to complete. + + If your goal is to cut off any request after a set amount of wall clock + time, consider having a second "watcher" thread to cut off a slow + request. + """ + + #: A sentinel object representing the default timeout value + DEFAULT_TIMEOUT = _GLOBAL_DEFAULT_TIMEOUT + + def __init__(self, total=None, connect=_Default, read=_Default): + self._connect = self._validate_timeout(connect, "connect") + self._read = self._validate_timeout(read, "read") + self.total = self._validate_timeout(total, "total") + self._start_connect = None + + def __repr__(self): + return "%s(connect=%r, read=%r, total=%r)" % ( + type(self).__name__, + self._connect, + self._read, + self.total, + ) + + # __str__ provided for backwards compatibility + __str__ = __repr__ + + @classmethod + def _validate_timeout(cls, value, name): + """Check that a timeout attribute is valid. + + :param value: The timeout value to validate + :param name: The name of the timeout attribute to validate. This is + used to specify in error messages. + :return: The validated and casted version of the given value. + :raises ValueError: If it is a numeric value less than or equal to + zero, or the type is not an integer, float, or None. + """ + if value is _Default: + return cls.DEFAULT_TIMEOUT + + if value is None or value is cls.DEFAULT_TIMEOUT: + return value + + if isinstance(value, bool): + raise ValueError( + "Timeout cannot be a boolean value. It must " + "be an int, float or None." + ) + try: + float(value) + except (TypeError, ValueError): + raise ValueError( + "Timeout value %s was %s, but it must be an " + "int, float or None." % (name, value) + ) + + try: + if value <= 0: + raise ValueError( + "Attempted to set %s timeout to %s, but the " + "timeout cannot be set to a value less " + "than or equal to 0." % (name, value) + ) + except TypeError: + # Python 3 + raise ValueError( + "Timeout value %s was %s, but it must be an " + "int, float or None." % (name, value) + ) + + return value + + @classmethod + def from_float(cls, timeout): + """Create a new Timeout from a legacy timeout value. + + The timeout value used by httplib.py sets the same timeout on the + connect(), and recv() socket requests. This creates a :class:`Timeout` + object that sets the individual timeouts to the ``timeout`` value + passed to this function. + + :param timeout: The legacy timeout value. + :type timeout: integer, float, sentinel default object, or None + :return: Timeout object + :rtype: :class:`Timeout` + """ + return Timeout(read=timeout, connect=timeout) + + def clone(self): + """Create a copy of the timeout object + + Timeout properties are stored per-pool but each request needs a fresh + Timeout object to ensure each one has its own start/stop configured. + + :return: a copy of the timeout object + :rtype: :class:`Timeout` + """ + # We can't use copy.deepcopy because that will also create a new object + # for _GLOBAL_DEFAULT_TIMEOUT, which socket.py uses as a sentinel to + # detect the user default. + return Timeout(connect=self._connect, read=self._read, total=self.total) + + def start_connect(self): + """Start the timeout clock, used during a connect() attempt + + :raises urllib3.exceptions.TimeoutStateError: if you attempt + to start a timer that has been started already. + """ + if self._start_connect is not None: + raise TimeoutStateError("Timeout timer has already been started.") + self._start_connect = current_time() + return self._start_connect + + def get_connect_duration(self): + """Gets the time elapsed since the call to :meth:`start_connect`. + + :return: Elapsed time in seconds. + :rtype: float + :raises urllib3.exceptions.TimeoutStateError: if you attempt + to get duration for a timer that hasn't been started. + """ + if self._start_connect is None: + raise TimeoutStateError( + "Can't get connect duration for timer that has not started." + ) + return current_time() - self._start_connect + + @property + def connect_timeout(self): + """Get the value to use when setting a connection timeout. + + This will be a positive float or integer, the value None + (never timeout), or the default system timeout. + + :return: Connect timeout. + :rtype: int, float, :attr:`Timeout.DEFAULT_TIMEOUT` or None + """ + if self.total is None: + return self._connect + + if self._connect is None or self._connect is self.DEFAULT_TIMEOUT: + return self.total + + return min(self._connect, self.total) + + @property + def read_timeout(self): + """Get the value for the read timeout. + + This assumes some time has elapsed in the connection timeout and + computes the read timeout appropriately. + + If self.total is set, the read timeout is dependent on the amount of + time taken by the connect timeout. If the connection time has not been + established, a :exc:`~urllib3.exceptions.TimeoutStateError` will be + raised. + + :return: Value to use for the read timeout. + :rtype: int, float, :attr:`Timeout.DEFAULT_TIMEOUT` or None + :raises urllib3.exceptions.TimeoutStateError: If :meth:`start_connect` + has not yet been called on this object. + """ + if ( + self.total is not None + and self.total is not self.DEFAULT_TIMEOUT + and self._read is not None + and self._read is not self.DEFAULT_TIMEOUT + ): + # In case the connect timeout has not yet been established. + if self._start_connect is None: + return self._read + return max(0, min(self.total - self.get_connect_duration(), self._read)) + elif self.total is not None and self.total is not self.DEFAULT_TIMEOUT: + return max(0, self.total - self.get_connect_duration()) + else: + return self._read diff --git a/openpype/hosts/fusion/vendor/urllib3/util/url.py b/openpype/hosts/fusion/vendor/urllib3/util/url.py new file mode 100644 index 0000000000..81a03da9e3 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/url.py @@ -0,0 +1,432 @@ +from __future__ import absolute_import + +import re +from collections import namedtuple + +from ..exceptions import LocationParseError +from ..packages import six + +url_attrs = ["scheme", "auth", "host", "port", "path", "query", "fragment"] + +# We only want to normalize urls with an HTTP(S) scheme. +# urllib3 infers URLs without a scheme (None) to be http. +NORMALIZABLE_SCHEMES = ("http", "https", None) + +# Almost all of these patterns were derived from the +# 'rfc3986' module: https://github.com/python-hyper/rfc3986 +PERCENT_RE = re.compile(r"%[a-fA-F0-9]{2}") +SCHEME_RE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+-]*:|/)") +URI_RE = re.compile( + r"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):)?" + r"(?://([^\\/?#]*))?" + r"([^?#]*)" + r"(?:\?([^#]*))?" + r"(?:#(.*))?$", + re.UNICODE | re.DOTALL, +) + +IPV4_PAT = r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}" +HEX_PAT = "[0-9A-Fa-f]{1,4}" +LS32_PAT = "(?:{hex}:{hex}|{ipv4})".format(hex=HEX_PAT, ipv4=IPV4_PAT) +_subs = {"hex": HEX_PAT, "ls32": LS32_PAT} +_variations = [ + # 6( h16 ":" ) ls32 + "(?:%(hex)s:){6}%(ls32)s", + # "::" 5( h16 ":" ) ls32 + "::(?:%(hex)s:){5}%(ls32)s", + # [ h16 ] "::" 4( h16 ":" ) ls32 + "(?:%(hex)s)?::(?:%(hex)s:){4}%(ls32)s", + # [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 + "(?:(?:%(hex)s:)?%(hex)s)?::(?:%(hex)s:){3}%(ls32)s", + # [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 + "(?:(?:%(hex)s:){0,2}%(hex)s)?::(?:%(hex)s:){2}%(ls32)s", + # [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 + "(?:(?:%(hex)s:){0,3}%(hex)s)?::%(hex)s:%(ls32)s", + # [ *4( h16 ":" ) h16 ] "::" ls32 + "(?:(?:%(hex)s:){0,4}%(hex)s)?::%(ls32)s", + # [ *5( h16 ":" ) h16 ] "::" h16 + "(?:(?:%(hex)s:){0,5}%(hex)s)?::%(hex)s", + # [ *6( h16 ":" ) h16 ] "::" + "(?:(?:%(hex)s:){0,6}%(hex)s)?::", +] + +UNRESERVED_PAT = r"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._!\-~" +IPV6_PAT = "(?:" + "|".join([x % _subs for x in _variations]) + ")" +ZONE_ID_PAT = "(?:%25|%)(?:[" + UNRESERVED_PAT + "]|%[a-fA-F0-9]{2})+" +IPV6_ADDRZ_PAT = r"\[" + IPV6_PAT + r"(?:" + ZONE_ID_PAT + r")?\]" +REG_NAME_PAT = r"(?:[^\[\]%:/?#]|%[a-fA-F0-9]{2})*" +TARGET_RE = re.compile(r"^(/[^?#]*)(?:\?([^#]*))?(?:#.*)?$") + +IPV4_RE = re.compile("^" + IPV4_PAT + "$") +IPV6_RE = re.compile("^" + IPV6_PAT + "$") +IPV6_ADDRZ_RE = re.compile("^" + IPV6_ADDRZ_PAT + "$") +BRACELESS_IPV6_ADDRZ_RE = re.compile("^" + IPV6_ADDRZ_PAT[2:-2] + "$") +ZONE_ID_RE = re.compile("(" + ZONE_ID_PAT + r")\]$") + +_HOST_PORT_PAT = ("^(%s|%s|%s)(?::([0-9]{0,5}))?$") % ( + REG_NAME_PAT, + IPV4_PAT, + IPV6_ADDRZ_PAT, +) +_HOST_PORT_RE = re.compile(_HOST_PORT_PAT, re.UNICODE | re.DOTALL) + +UNRESERVED_CHARS = set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-~" +) +SUB_DELIM_CHARS = set("!$&'()*+,;=") +USERINFO_CHARS = UNRESERVED_CHARS | SUB_DELIM_CHARS | {":"} +PATH_CHARS = USERINFO_CHARS | {"@", "/"} +QUERY_CHARS = FRAGMENT_CHARS = PATH_CHARS | {"?"} + + +class Url(namedtuple("Url", url_attrs)): + """ + Data structure for representing an HTTP URL. Used as a return value for + :func:`parse_url`. Both the scheme and host are normalized as they are + both case-insensitive according to RFC 3986. + """ + + __slots__ = () + + def __new__( + cls, + scheme=None, + auth=None, + host=None, + port=None, + path=None, + query=None, + fragment=None, + ): + if path and not path.startswith("/"): + path = "/" + path + if scheme is not None: + scheme = scheme.lower() + return super(Url, cls).__new__( + cls, scheme, auth, host, port, path, query, fragment + ) + + @property + def hostname(self): + """For backwards-compatibility with urlparse. We're nice like that.""" + return self.host + + @property + def request_uri(self): + """Absolute path including the query string.""" + uri = self.path or "/" + + if self.query is not None: + uri += "?" + self.query + + return uri + + @property + def netloc(self): + """Network location including host and port""" + if self.port: + return "%s:%d" % (self.host, self.port) + return self.host + + @property + def url(self): + """ + Convert self into a url + + This function should more or less round-trip with :func:`.parse_url`. The + returned url may not be exactly the same as the url inputted to + :func:`.parse_url`, but it should be equivalent by the RFC (e.g., urls + with a blank port will have : removed). + + Example: :: + + >>> U = parse_url('http://google.com/mail/') + >>> U.url + 'http://google.com/mail/' + >>> Url('http', 'username:password', 'host.com', 80, + ... '/path', 'query', 'fragment').url + 'http://username:password@host.com:80/path?query#fragment' + """ + scheme, auth, host, port, path, query, fragment = self + url = u"" + + # We use "is not None" we want things to happen with empty strings (or 0 port) + if scheme is not None: + url += scheme + u"://" + if auth is not None: + url += auth + u"@" + if host is not None: + url += host + if port is not None: + url += u":" + str(port) + if path is not None: + url += path + if query is not None: + url += u"?" + query + if fragment is not None: + url += u"#" + fragment + + return url + + def __str__(self): + return self.url + + +def split_first(s, delims): + """ + .. deprecated:: 1.25 + + Given a string and an iterable of delimiters, split on the first found + delimiter. Return two split parts and the matched delimiter. + + If not found, then the first part is the full input string. + + Example:: + + >>> split_first('foo/bar?baz', '?/=') + ('foo', 'bar?baz', '/') + >>> split_first('foo/bar?baz', '123') + ('foo/bar?baz', '', None) + + Scales linearly with number of delims. Not ideal for large number of delims. + """ + min_idx = None + min_delim = None + for d in delims: + idx = s.find(d) + if idx < 0: + continue + + if min_idx is None or idx < min_idx: + min_idx = idx + min_delim = d + + if min_idx is None or min_idx < 0: + return s, "", None + + return s[:min_idx], s[min_idx + 1 :], min_delim + + +def _encode_invalid_chars(component, allowed_chars, encoding="utf-8"): + """Percent-encodes a URI component without reapplying + onto an already percent-encoded component. + """ + if component is None: + return component + + component = six.ensure_text(component) + + # Normalize existing percent-encoded bytes. + # Try to see if the component we're encoding is already percent-encoded + # so we can skip all '%' characters but still encode all others. + component, percent_encodings = PERCENT_RE.subn( + lambda match: match.group(0).upper(), component + ) + + uri_bytes = component.encode("utf-8", "surrogatepass") + is_percent_encoded = percent_encodings == uri_bytes.count(b"%") + encoded_component = bytearray() + + for i in range(0, len(uri_bytes)): + # Will return a single character bytestring on both Python 2 & 3 + byte = uri_bytes[i : i + 1] + byte_ord = ord(byte) + if (is_percent_encoded and byte == b"%") or ( + byte_ord < 128 and byte.decode() in allowed_chars + ): + encoded_component += byte + continue + encoded_component.extend(b"%" + (hex(byte_ord)[2:].encode().zfill(2).upper())) + + return encoded_component.decode(encoding) + + +def _remove_path_dot_segments(path): + # See http://tools.ietf.org/html/rfc3986#section-5.2.4 for pseudo-code + segments = path.split("/") # Turn the path into a list of segments + output = [] # Initialize the variable to use to store output + + for segment in segments: + # '.' is the current directory, so ignore it, it is superfluous + if segment == ".": + continue + # Anything other than '..', should be appended to the output + elif segment != "..": + output.append(segment) + # In this case segment == '..', if we can, we should pop the last + # element + elif output: + output.pop() + + # If the path starts with '/' and the output is empty or the first string + # is non-empty + if path.startswith("/") and (not output or output[0]): + output.insert(0, "") + + # If the path starts with '/.' or '/..' ensure we add one more empty + # string to add a trailing '/' + if path.endswith(("/.", "/..")): + output.append("") + + return "/".join(output) + + +def _normalize_host(host, scheme): + if host: + if isinstance(host, six.binary_type): + host = six.ensure_str(host) + + if scheme in NORMALIZABLE_SCHEMES: + is_ipv6 = IPV6_ADDRZ_RE.match(host) + if is_ipv6: + match = ZONE_ID_RE.search(host) + if match: + start, end = match.span(1) + zone_id = host[start:end] + + if zone_id.startswith("%25") and zone_id != "%25": + zone_id = zone_id[3:] + else: + zone_id = zone_id[1:] + zone_id = "%" + _encode_invalid_chars(zone_id, UNRESERVED_CHARS) + return host[:start].lower() + zone_id + host[end:] + else: + return host.lower() + elif not IPV4_RE.match(host): + return six.ensure_str( + b".".join([_idna_encode(label) for label in host.split(".")]) + ) + return host + + +def _idna_encode(name): + if name and any([ord(x) > 128 for x in name]): + try: + import idna + except ImportError: + six.raise_from( + LocationParseError("Unable to parse URL without the 'idna' module"), + None, + ) + try: + return idna.encode(name.lower(), strict=True, std3_rules=True) + except idna.IDNAError: + six.raise_from( + LocationParseError(u"Name '%s' is not a valid IDNA label" % name), None + ) + return name.lower().encode("ascii") + + +def _encode_target(target): + """Percent-encodes a request target so that there are no invalid characters""" + path, query = TARGET_RE.match(target).groups() + target = _encode_invalid_chars(path, PATH_CHARS) + query = _encode_invalid_chars(query, QUERY_CHARS) + if query is not None: + target += "?" + query + return target + + +def parse_url(url): + """ + Given a url, return a parsed :class:`.Url` namedtuple. Best-effort is + performed to parse incomplete urls. Fields not provided will be None. + This parser is RFC 3986 compliant. + + The parser logic and helper functions are based heavily on + work done in the ``rfc3986`` module. + + :param str url: URL to parse into a :class:`.Url` namedtuple. + + Partly backwards-compatible with :mod:`urlparse`. + + Example:: + + >>> parse_url('http://google.com/mail/') + Url(scheme='http', host='google.com', port=None, path='/mail/', ...) + >>> parse_url('google.com:80') + Url(scheme=None, host='google.com', port=80, path=None, ...) + >>> parse_url('/foo?bar') + Url(scheme=None, host=None, port=None, path='/foo', query='bar', ...) + """ + if not url: + # Empty + return Url() + + source_url = url + if not SCHEME_RE.search(url): + url = "//" + url + + try: + scheme, authority, path, query, fragment = URI_RE.match(url).groups() + normalize_uri = scheme is None or scheme.lower() in NORMALIZABLE_SCHEMES + + if scheme: + scheme = scheme.lower() + + if authority: + auth, _, host_port = authority.rpartition("@") + auth = auth or None + host, port = _HOST_PORT_RE.match(host_port).groups() + if auth and normalize_uri: + auth = _encode_invalid_chars(auth, USERINFO_CHARS) + if port == "": + port = None + else: + auth, host, port = None, None, None + + if port is not None: + port = int(port) + if not (0 <= port <= 65535): + raise LocationParseError(url) + + host = _normalize_host(host, scheme) + + if normalize_uri and path: + path = _remove_path_dot_segments(path) + path = _encode_invalid_chars(path, PATH_CHARS) + if normalize_uri and query: + query = _encode_invalid_chars(query, QUERY_CHARS) + if normalize_uri and fragment: + fragment = _encode_invalid_chars(fragment, FRAGMENT_CHARS) + + except (ValueError, AttributeError): + return six.raise_from(LocationParseError(source_url), None) + + # For the sake of backwards compatibility we put empty + # string values for path if there are any defined values + # beyond the path in the URL. + # TODO: Remove this when we break backwards compatibility. + if not path: + if query is not None or fragment is not None: + path = "" + else: + path = None + + # Ensure that each part of the URL is a `str` for + # backwards compatibility. + if isinstance(url, six.text_type): + ensure_func = six.ensure_text + else: + ensure_func = six.ensure_str + + def ensure_type(x): + return x if x is None else ensure_func(x) + + return Url( + scheme=ensure_type(scheme), + auth=ensure_type(auth), + host=ensure_type(host), + port=port, + path=ensure_type(path), + query=ensure_type(query), + fragment=ensure_type(fragment), + ) + + +def get_host(url): + """ + Deprecated. Use :func:`parse_url` instead. + """ + p = parse_url(url) + return p.scheme or "http", p.hostname, p.port diff --git a/openpype/hosts/fusion/vendor/urllib3/util/wait.py b/openpype/hosts/fusion/vendor/urllib3/util/wait.py new file mode 100644 index 0000000000..c280646c7b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/wait.py @@ -0,0 +1,153 @@ +import errno +import select +import sys +from functools import partial + +try: + from time import monotonic +except ImportError: + from time import time as monotonic + +__all__ = ["NoWayToWaitForSocketError", "wait_for_read", "wait_for_write"] + + +class NoWayToWaitForSocketError(Exception): + pass + + +# How should we wait on sockets? +# +# There are two types of APIs you can use for waiting on sockets: the fancy +# modern stateful APIs like epoll/kqueue, and the older stateless APIs like +# select/poll. The stateful APIs are more efficient when you have a lots of +# sockets to keep track of, because you can set them up once and then use them +# lots of times. But we only ever want to wait on a single socket at a time +# and don't want to keep track of state, so the stateless APIs are actually +# more efficient. So we want to use select() or poll(). +# +# Now, how do we choose between select() and poll()? On traditional Unixes, +# select() has a strange calling convention that makes it slow, or fail +# altogether, for high-numbered file descriptors. The point of poll() is to fix +# that, so on Unixes, we prefer poll(). +# +# On Windows, there is no poll() (or at least Python doesn't provide a wrapper +# for it), but that's OK, because on Windows, select() doesn't have this +# strange calling convention; plain select() works fine. +# +# So: on Windows we use select(), and everywhere else we use poll(). We also +# fall back to select() in case poll() is somehow broken or missing. + +if sys.version_info >= (3, 5): + # Modern Python, that retries syscalls by default + def _retry_on_intr(fn, timeout): + return fn(timeout) + + +else: + # Old and broken Pythons. + def _retry_on_intr(fn, timeout): + if timeout is None: + deadline = float("inf") + else: + deadline = monotonic() + timeout + + while True: + try: + return fn(timeout) + # OSError for 3 <= pyver < 3.5, select.error for pyver <= 2.7 + except (OSError, select.error) as e: + # 'e.args[0]' incantation works for both OSError and select.error + if e.args[0] != errno.EINTR: + raise + else: + timeout = deadline - monotonic() + if timeout < 0: + timeout = 0 + if timeout == float("inf"): + timeout = None + continue + + +def select_wait_for_socket(sock, read=False, write=False, timeout=None): + if not read and not write: + raise RuntimeError("must specify at least one of read=True, write=True") + rcheck = [] + wcheck = [] + if read: + rcheck.append(sock) + if write: + wcheck.append(sock) + # When doing a non-blocking connect, most systems signal success by + # marking the socket writable. Windows, though, signals success by marked + # it as "exceptional". We paper over the difference by checking the write + # sockets for both conditions. (The stdlib selectors module does the same + # thing.) + fn = partial(select.select, rcheck, wcheck, wcheck) + rready, wready, xready = _retry_on_intr(fn, timeout) + return bool(rready or wready or xready) + + +def poll_wait_for_socket(sock, read=False, write=False, timeout=None): + if not read and not write: + raise RuntimeError("must specify at least one of read=True, write=True") + mask = 0 + if read: + mask |= select.POLLIN + if write: + mask |= select.POLLOUT + poll_obj = select.poll() + poll_obj.register(sock, mask) + + # For some reason, poll() takes timeout in milliseconds + def do_poll(t): + if t is not None: + t *= 1000 + return poll_obj.poll(t) + + return bool(_retry_on_intr(do_poll, timeout)) + + +def null_wait_for_socket(*args, **kwargs): + raise NoWayToWaitForSocketError("no select-equivalent available") + + +def _have_working_poll(): + # Apparently some systems have a select.poll that fails as soon as you try + # to use it, either due to strange configuration or broken monkeypatching + # from libraries like eventlet/greenlet. + try: + poll_obj = select.poll() + _retry_on_intr(poll_obj.poll, 0) + except (AttributeError, OSError): + return False + else: + return True + + +def wait_for_socket(*args, **kwargs): + # We delay choosing which implementation to use until the first time we're + # called. We could do it at import time, but then we might make the wrong + # decision if someone goes wild with monkeypatching select.poll after + # we're imported. + global wait_for_socket + if _have_working_poll(): + wait_for_socket = poll_wait_for_socket + elif hasattr(select, "select"): + wait_for_socket = select_wait_for_socket + else: # Platform-specific: Appengine. + wait_for_socket = null_wait_for_socket + return wait_for_socket(*args, **kwargs) + + +def wait_for_read(sock, timeout=None): + """Waits for reading to be available on a given socket. + Returns True if the socket is readable, or False if the timeout expired. + """ + return wait_for_socket(sock, read=True, timeout=timeout) + + +def wait_for_write(sock, timeout=None): + """Waits for writing to be available on a given socket. + Returns True if the socket is readable, or False if the timeout expired. + """ + return wait_for_socket(sock, write=True, timeout=timeout) From 851f234237d69dd8ccdea76304a2f1ab7d7a564a Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 16 Sep 2023 03:24:27 +0000 Subject: [PATCH 404/429] [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 c4a87e7843..bbed1b0ef3 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.7-nightly.1" +__version__ = "3.16.7-nightly.2" From 10ed783907dbd3df9c511c71cf144b75ccd810f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 Sep 2023 03:25:05 +0000 Subject: [PATCH 405/429] 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 35564c2bf0..cf3cb8ba1a 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.7-nightly.2 - 3.16.7-nightly.1 - 3.16.6 - 3.16.6-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.1 - 3.14.9 - 3.14.9-nightly.5 - - 3.14.9-nightly.4 validations: required: true - type: dropdown From 73e928efc99dbcd90b84d09cd1de0397ed3f633b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Sep 2023 10:12:58 +0200 Subject: [PATCH 406/429] Fix - _id key used instead of id (#5626) Just 'id' is not returned because value in fields. --- openpype/client/server/entities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 39322627bb..3ee62a3172 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -422,7 +422,7 @@ def get_last_version_by_subset_name( if not subset: return None return get_last_version_by_subset_id( - project_name, subset["id"], fields=fields + project_name, subset["_id"], fields=fields ) From 36a8976c580d822c816992c752181b48a884202d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 18 Sep 2023 14:55:07 +0200 Subject: [PATCH 407/429] AYON: Mark deprecated settings in Maya (#5627) * Mark color management preferences as deprecated * add migration hint --- server_addon/maya/server/settings/imageio.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server_addon/maya/server/settings/imageio.py b/server_addon/maya/server/settings/imageio.py index 7512bfe253..946a14c866 100644 --- a/server_addon/maya/server/settings/imageio.py +++ b/server_addon/maya/server/settings/imageio.py @@ -39,8 +39,10 @@ class ImageIOFileRulesModel(BaseSettingsModel): class ColorManagementPreferenceV2Model(BaseSettingsModel): - """Color Management Preference v2 (Maya 2022+).""" - _layout = "expanded" + """Color Management Preference v2 (Maya 2022+). + + Please migrate all to 'imageio/workfile' and enable it. + """ enabled: bool = Field(True, title="Use Color Management Preference v2") @@ -51,7 +53,6 @@ class ColorManagementPreferenceV2Model(BaseSettingsModel): class ColorManagementPreferenceModel(BaseSettingsModel): """Color Management Preference (legacy).""" - _layout = "expanded" renderSpace: str = Field(title="Rendering Space") viewTransform: str = Field(title="Viewer Transform ") @@ -89,11 +90,11 @@ class ImageIOSettings(BaseSettingsModel): # Deprecated colorManagementPreference_v2: ColorManagementPreferenceV2Model = Field( default_factory=ColorManagementPreferenceV2Model, - title="Color Management Preference v2 (Maya 2022+)" + title="DEPRECATED: Color Management Preference v2 (Maya 2022+)" ) colorManagementPreference: ColorManagementPreferenceModel = Field( default_factory=ColorManagementPreferenceModel, - title="Color Management Preference (legacy)" + title="DEPRECATED: Color Management Preference (legacy)" ) From 9f040265e7cd965df20137d9da12396fa8338310 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 18 Sep 2023 16:07:54 +0100 Subject: [PATCH 408/429] Remove context prompt. --- openpype/hosts/maya/api/pipeline.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 60495ac652..3647ec0b6b 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -659,17 +659,6 @@ def on_task_changed(): lib.set_context_settings() lib.update_content_on_context_change() - msg = " project: {}\n asset: {}\n task:{}".format( - get_current_project_name(), - get_current_asset_name(), - get_current_task_name() - ) - - lib.show_message( - "Context was changed", - ("Context was changed to:\n{}".format(msg)), - ) - def before_workfile_open(): if handle_workfile_locks(): From 6c70df2a2e482cc1ad21d9f62b05a30e4282865a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 18 Sep 2023 21:02:58 +0300 Subject: [PATCH 409/429] remove unnecessary logic --- .../hosts/houdini/plugins/load/load_fbx.py | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 7e7f0c04e5..cac22d62d4 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -21,8 +21,9 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - # get file path - file_path = self.get_file_path(context=context) + # get file path from context + file_path = self.filepath_from_context(context) + file_path = file_path.replace("\\", "/") # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) @@ -56,8 +57,9 @@ class FbxLoader(load.LoaderPlugin): self.log.error("Could not find node of type `file`") return - # Update the file path - file_path = self.get_file_path(representation=representation) + # Update the file path from representation + file_path = get_representation_path(representation) + file_path = file_path.replace("\\", "/") file_node.setParms({"file": file_path}) @@ -72,19 +74,6 @@ class FbxLoader(load.LoaderPlugin): def switch(self, container, representation): self.update(container, representation) - def get_file_path(self, context=None, representation=None): - """Return formatted file path.""" - - # Format file name, Houdini only wants forward slashes - if context: - file_path = self.filepath_from_context(context) - elif representation: - file_path = get_representation_path(representation) - else: - return "" - - return file_path.replace("\\", "/") - def get_node_name(self, context, name=None, namespace=None): """Define node name.""" From b8997c5ae49841ed3dfc82c30d394909a3b88722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 19 Sep 2023 14:23:45 +0200 Subject: [PATCH 410/429] create symlinks for ssl libs (#5633) build was missing `libssl.1.1.so` and `libcrypto.1.1.so` symlinks needed by the executable itself, because python is now explicitly build with OpenSSL 1.1.1 --- Dockerfile.centos7 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile.centos7 b/Dockerfile.centos7 index 9217140f20..ab1d3f8253 100644 --- a/Dockerfile.centos7 +++ b/Dockerfile.centos7 @@ -109,6 +109,8 @@ RUN source $HOME/.bashrc \ RUN cp /usr/lib64/libffi* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/openssl11/libssl* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/openssl11/libcrypto* ./build/exe.linux-x86_64-3.9/lib \ + && ln -sr ./build/exe.linux-x86_64-3.9/lib/libssl.so ./build/exe.linux-x86_64-3.9/lib/libssl.1.1.so \ + && ln -sr ./build/exe.linux-x86_64-3.9/lib/libcrypto.so ./build/exe.linux-x86_64-3.9/lib/libcrypto.1.1.so \ && cp /root/.pyenv/versions/${OPENPYPE_PYTHON_VERSION}/lib/libpython* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/libxcb* ./build/exe.linux-x86_64-3.9/vendor/python/PySide2/Qt/lib From a8fa44c3fda18d0799fc171cb6461ba80672a36d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 19 Sep 2023 16:52:51 +0200 Subject: [PATCH 411/429] AYON: Ignore separated modules (#5619) * ayon mode explicitly ignores addons that have own repository * change warning message of missing addon directory to debug * Better log message --- openpype/modules/base.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 84e213288c..a3c21718b9 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -59,6 +59,14 @@ IGNORED_DEFAULT_FILENAMES = ( "example_addons", "default_modules", ) +# Modules that won't be loaded in AYON mode from "./openpype/modules" +# - the same modules are ignored in "./server_addon/create_ayon_addons.py" +IGNORED_FILENAMES_IN_AYON = { + "ftrack", + "shotgrid", + "sync_server", + "slack", +} # Inherit from `object` for Python 2 hosts @@ -392,9 +400,9 @@ def _load_ayon_addons(openpype_modules, modules_key, log): folder_name = "{}_{}".format(addon_name, addon_version) addon_dir = os.path.join(addons_dir, folder_name) if not os.path.exists(addon_dir): - log.warning(( - "Directory for addon {} {} does not exists. Path \"{}\"" - ).format(addon_name, addon_version, addon_dir)) + log.debug(( + "No localized client code found for addon {} {}." + ).format(addon_name, addon_version)) continue sys.path.insert(0, addon_dir) @@ -483,6 +491,10 @@ def _load_modules(): is_in_current_dir = dirpath == current_dir is_in_host_dir = dirpath == hosts_dir + ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES) + if AYON_SERVER_ENABLED: + ignored_current_dir_filenames |= IGNORED_FILENAMES_IN_AYON + for filename in os.listdir(dirpath): # Ignore filenames if filename in IGNORED_FILENAMES: @@ -490,7 +502,7 @@ def _load_modules(): if ( is_in_current_dir - and filename in IGNORED_DEFAULT_FILENAMES + and filename in ignored_current_dir_filenames ): continue From bed1e35d20dfc949a8b8c3864bbb526644fa37df Mon Sep 17 00:00:00 2001 From: 64qam Date: Tue, 19 Sep 2023 16:54:48 +0200 Subject: [PATCH 412/429] Default create a desktop icon is checked (#5636) --- inno_setup.iss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/inno_setup.iss b/inno_setup.iss index 418bedbd4d..d9a41d22ee 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -36,7 +36,7 @@ WizardStyle=modern Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" [InstallDelete] ; clean everything in previous installation folder @@ -53,4 +53,3 @@ Name: "{autodesktop}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe" [Run] Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent - From 50af3321ae9a9e0118f6b25eb7845ce6f9ab1762 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 19 Sep 2023 17:18:46 +0200 Subject: [PATCH 413/429] AYON: Avoid creation of duplicated links (#5593) * create set of output links before creation * find all existing links before creating them * Added small comment --- .../publish/integrate_inputlinks_ayon.py | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/openpype/plugins/publish/integrate_inputlinks_ayon.py b/openpype/plugins/publish/integrate_inputlinks_ayon.py index 180524cd08..28684aa889 100644 --- a/openpype/plugins/publish/integrate_inputlinks_ayon.py +++ b/openpype/plugins/publish/integrate_inputlinks_ayon.py @@ -1,7 +1,11 @@ import collections import pyblish.api -from ayon_api import create_link, make_sure_link_type_exists +from ayon_api import ( + create_link, + make_sure_link_type_exists, + get_versions_links, +) from openpype import AYON_SERVER_ENABLED @@ -124,6 +128,33 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): version_entity["_id"], ) + def _get_existing_links(self, project_name, link_type, entity_ids): + """Find all existing links for given version ids. + + Args: + project_name (str): Name of project. + link_type (str): Type of link. + entity_ids (set[str]): Set of version ids. + + Returns: + dict[str, set[str]]: Existing links by version id. + """ + + output = collections.defaultdict(set) + if not entity_ids: + return output + + existing_in_links = get_versions_links( + project_name, entity_ids, [link_type], "output" + ) + + for entity_id, links in existing_in_links.items(): + if not links: + continue + for link in links: + output[entity_id].add(link["entityId"]) + return output + def create_links_on_server(self, context, new_links): """Create new links on server. @@ -144,16 +175,32 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): # Create link themselves for link_type, items in new_links.items(): + mapping = collections.defaultdict(set) + # Make sure there are no duplicates of src > dst ids for item in items: - input_id, output_id = item - create_link( - project_name, - link_type, - input_id, - "version", - output_id, - "version" - ) + _input_id, _output_id = item + mapping[_input_id].add(_output_id) + + existing_links_by_in_id = self._get_existing_links( + project_name, link_type, set(mapping.keys()) + ) + + for input_id, output_ids in mapping.items(): + existing_links = existing_links_by_in_id[input_id] + for output_id in output_ids: + # Skip creation of link if already exists + # NOTE: AYON server does not support + # to have same links + if output_id in existing_links: + continue + create_link( + project_name, + link_type, + input_id, + "version", + output_id, + "version" + ) if not AYON_SERVER_ENABLED: From 85951d58f64920b33e9c6902569d890dcc1690f2 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 19 Sep 2023 15:31:39 +0000 Subject: [PATCH 414/429] [Automated] Release --- CHANGELOG.md | 251 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 253 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d7620869b..935e7dbc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,257 @@ # Changelog +## [3.16.7](https://github.com/ynput/OpenPype/tree/3.16.7) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.6...3.16.7) + +### **🆕 New features** + + +
+Maya: Extract active view as thumbnail when no thumbnail set #5426 + +This sets the Maya instance's thumbnail to the current active view if no thumbnail was set yet. + + +___ + +
+ + +
+Maya: Implement USD publish and load using native `mayaUsdPlugin` #5573 + +Implement Creator and Loaders for extraction and loading of USD files using Maya's own `mayaUsdPlugin`.Also adds support to load a `usd` file into an Arnold Standin (`aiStandin`) and assigning looks to it. + + +___ + +
+ + +
+AYON: Ignore separated modules #5619 + +Do not load already separated modules from default directory. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: Reduce amount of code for Collect Looks #5253 + +- Refactor `get_file_node_files` because popping from `paths` by index should have been done in reversed order anyway. It's now changed to not need popping at all. +- Removed unused `RENDERER_NODE_TYPES` and if-branch which collected `node_attrs` list which was unused + collected members which was also done outside of the if branch and thus generated no extra data. +- Collected all materials from look set attributes at once instead of multiple queries +- Collected all file nodes in history from a single query instead of per type +- Restructured assignment of `instance.data["resources"]` to be more readable +- Cached `PXR_NODES` only ones (Note: plugin load is checked on discovery of the collect look plugin) instead of querying plugin load and its nodes per file node per attribute +- Removed some debug logs or combined some messages + + +___ + +
+ + +
+AYON: Mark deprecated settings in Maya #5627 + +Added deprecated info to docstrings of maya colormanagement settings.Resolves: https://github.com/ynput/OpenPype/issues/5556 + + +___ + +
+ + +
+Max: switching versions of maxScene maintain parentage/links with the loaders #5424 + +When using scene inventory to manage or update the version of the loading objects, the linked modifiers or parentage of the objects would be kept.Meanwhile, loaded objects from all loaders no longer parented to the container with OP Data. + + +___ + +
+ + +
+3ds max: small tweaks to obj extractor and model publishing flow #5605 + +There migh be situation where OBJ Extractor passes without failure, but no obj file is produced. This is adding simple check directly into the extractor to catch it earlier then in the integration phase. Also switched `Validate USD Plugin` to optional, because it was always run no matter if the Extract USD was enabled or not, hindering testing (and publishing). + + +___ + +
+ + +
+TVPaint: Plugin can be reopened #5610 + +TVPaint plugin can be reopened. + + +___ + +
+ + +
+Maya: Remove context prompt #5632 + +More of a plea than a PR, but could we please remove the context prompt in Maya when switching tasks? + + +___ + +
+ + +
+General: Create a desktop icon is checked #5636 + +In OP Installer `Create a desktop icon` is checked by default. +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Extract look is not AYON compatible - OP-5375 #5341 + +The textures that would use hardlinking are going through texture processors. Currently all texture processors are hardcoded to copy texture instead of respecting the settings of forcing to copy.The texture processors were last modified 4 months ago, so effectively all clients that are on any pipeline updated in the last 4 months wont be utilizing hardlinking at all, since the hardcoded texture processors will copy texture no matter the OS.This opts for completely disabling the hardlinking feature, while we figure out what to do about it. + + +___ + +
+ + +
+Maya: Multiverse USD Override inherit from correct new style creator #5566 + +Fix Creator for Multiverse USD Override by inheriting from correct new style creator class type + + +___ + +
+ + +
+Max: Bug Fix Alembic Loaders with Ornatrix #5434 + +Bugfix the alembic loader with both ornatrix alembic and max alembic supportsAdd the ornatrix alembic loaders for loading the alembic with Ornatrix-related modifiers. + + +___ + +
+ + +
+AYON: Avoid creation of duplicated links #5593 + +Handle cases when an existing link should be recreated and do not create the same link multitple times during single publishing. + + +___ + +
+ + +
+Extract Review: Multilayer specification for ffmpeg #5613 + +Extract review is specifying layer name when exr is multilayer. + + +___ + +
+ + +
+Fussion: added support for Fusion 17 #5614 + +Fusion 17 still uses Python 3.6 which causes issues with some our delivered libraries. Vendorized necessary set for Python 3.6 + + +___ + +
+ + +
+Publisher: Fix screenshot widget #5615 + +Use correct super method name.EDITED:Removed fade animation which is not triggered at some cases, e.g. in Nuke the animation does not start. I do expect that is caused by `exec_` on the dialog, which blocks event processing to the animation, even when I've added the window as parent it still didn't trigger registered callback.Modified how the "empty" space is not filled by using paths instead of clear mode on painter. Added render hints to add antialiasing. + + +___ + +
+ + +
+Photoshop: auto_images without alpha will not fail #5620 + +ExtractReview caused issue on `auto_image` instance without alpha channel, this fixes it. + + +___ + +
+ + +
+Fix - _id key used instead of id in get_last_version_by_subset_name #5626 + +Just 'id' is not returned because value in fields. Caused KeyError. + + +___ + +
+ + +
+Bugfix: create symlinks for ssl libs on Centos 7 #5633 + +Docker build was missing `libssl.1.1.so` and `libcrypto.1.1.so` symlinks needed by the executable itself, because Python is now explicitly built with OpenSSL 1.1.1 + + +___ + +
+ +### **📃 Documentation** + + +
+Documentation/local settings #5102 + +I completed the "Working with local settings" page. I updated the screenshot, wrote an explanation for each empty category, and if available, linked the more detailed pages already existing. I also added the "Environments" category. + + +___ + +
+ + + + ## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6) diff --git a/openpype/version.py b/openpype/version.py index bbed1b0ef3..c593f0f71f 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.7-nightly.2" +__version__ = "3.16.7" diff --git a/pyproject.toml b/pyproject.toml index f859e1aff4..b394a895e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.6" # OpenPype +version = "3.16.7" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 16641e750045f8639a7138b83580ba089abfc492 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 Sep 2023 15:32:44 +0000 Subject: [PATCH 415/429] 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 cf3cb8ba1a..9159d3fe4a 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.7 - 3.16.7-nightly.2 - 3.16.7-nightly.1 - 3.16.6 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.2 - 3.14.10-nightly.1 - 3.14.9 - - 3.14.9-nightly.5 validations: required: true - type: dropdown From 982c72224cdbf3b92ac954d42e03769a79bbd6f4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 27 Jul 2023 10:21:05 +0200 Subject: [PATCH 416/429] Chore: Remove schema from OpenPype root (#5355) * removed schema from openpype root * removed schema from include files --- schema/application-1.0.json | 68 ---------------- schema/asset-1.0.json | 35 -------- schema/asset-2.0.json | 55 ------------- schema/asset-3.0.json | 55 ------------- schema/config-1.0.json | 85 -------------------- schema/config-1.1.json | 87 -------------------- schema/config-2.0.json | 87 -------------------- schema/container-1.0.json | 100 ----------------------- schema/container-2.0.json | 59 -------------- schema/hero_version-1.0.json | 44 ---------- schema/inventory-1.0.json | 10 --- schema/inventory-1.1.json | 10 --- schema/project-2.0.json | 86 -------------------- schema/project-2.1.json | 86 -------------------- schema/project-3.0.json | 59 -------------- schema/representation-1.0.json | 28 ------- schema/representation-2.0.json | 78 ------------------ schema/session-1.0.json | 143 --------------------------------- schema/session-2.0.json | 134 ------------------------------ schema/session-3.0.json | 81 ------------------- schema/shaders-1.0.json | 32 -------- schema/subset-1.0.json | 35 -------- schema/subset-2.0.json | 51 ------------ schema/subset-3.0.json | 62 -------------- schema/thumbnail-1.0.json | 42 ---------- schema/version-1.0.json | 50 ------------ schema/version-2.0.json | 92 --------------------- schema/version-3.0.json | 84 ------------------- schema/workfile-1.0.json | 52 ------------ setup.py | 1 - 30 files changed, 1891 deletions(-) delete mode 100644 schema/application-1.0.json delete mode 100644 schema/asset-1.0.json delete mode 100644 schema/asset-2.0.json delete mode 100644 schema/asset-3.0.json delete mode 100644 schema/config-1.0.json delete mode 100644 schema/config-1.1.json delete mode 100644 schema/config-2.0.json delete mode 100644 schema/container-1.0.json delete mode 100644 schema/container-2.0.json delete mode 100644 schema/hero_version-1.0.json delete mode 100644 schema/inventory-1.0.json delete mode 100644 schema/inventory-1.1.json delete mode 100644 schema/project-2.0.json delete mode 100644 schema/project-2.1.json delete mode 100644 schema/project-3.0.json delete mode 100644 schema/representation-1.0.json delete mode 100644 schema/representation-2.0.json delete mode 100644 schema/session-1.0.json delete mode 100644 schema/session-2.0.json delete mode 100644 schema/session-3.0.json delete mode 100644 schema/shaders-1.0.json delete mode 100644 schema/subset-1.0.json delete mode 100644 schema/subset-2.0.json delete mode 100644 schema/subset-3.0.json delete mode 100644 schema/thumbnail-1.0.json delete mode 100644 schema/version-1.0.json delete mode 100644 schema/version-2.0.json delete mode 100644 schema/version-3.0.json delete mode 100644 schema/workfile-1.0.json diff --git a/schema/application-1.0.json b/schema/application-1.0.json deleted file mode 100644 index 953abee569..0000000000 --- a/schema/application-1.0.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:application-1.0", - "description": "An application definition.", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "label", - "application_dir", - "executable" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "label": { - "description": "Nice name of application.", - "type": "string" - }, - "application_dir": { - "description": "Name of directory used for application resources.", - "type": "string" - }, - "executable": { - "description": "Name of callable executable, this is called to launch the application", - "type": "string" - }, - "description": { - "description": "Description of application.", - "type": "string" - }, - "environment": { - "description": "Key/value pairs for environment variables related to this application. Supports lists for paths, such as PYTHONPATH.", - "type": "object", - "items": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - } - }, - "default_dirs": { - "type": "array", - "items": { - "type": "string" - } - }, - "copy": { - "type": "object", - "patternProperties": { - "^.*$": { - "anyOf": [ - {"type": "string"}, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - } -} diff --git a/schema/asset-1.0.json b/schema/asset-1.0.json deleted file mode 100644 index ab104c002a..0000000000 --- a/schema/asset-1.0.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:asset-1.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "name", - "subsets" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "name": { - "description": "Name of directory", - "type": "string" - }, - "subsets": { - "type": "array", - "items": { - "$ref": "subset.json" - } - } - }, - - "definitions": {} -} diff --git a/schema/asset-2.0.json b/schema/asset-2.0.json deleted file mode 100644 index b894d79792..0000000000 --- a/schema/asset-2.0.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:asset-2.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "silo", - "data" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:asset-2.0"], - "example": "openpype:asset-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["asset"], - "example": "asset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of asset", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "Bruce" - }, - "silo": { - "description": "Group or container of asset", - "type": "string", - "example": "assets" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": {"key": "value"} - } - }, - - "definitions": {} -} diff --git a/schema/asset-3.0.json b/schema/asset-3.0.json deleted file mode 100644 index 948704d2a1..0000000000 --- a/schema/asset-3.0.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:asset-3.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:asset-3.0"], - "example": "openpype:asset-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["asset"], - "example": "asset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of asset", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "Bruce" - }, - "silo": { - "description": "Group or container of asset", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "assets" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": {"key": "value"} - } - }, - - "definitions": {} -} diff --git a/schema/config-1.0.json b/schema/config-1.0.json deleted file mode 100644 index 49398a57cd..0000000000 --- a/schema/config-1.0.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.0", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": false, - "required": [ - "tasks", - "apps" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "template": { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^.*$": { - "type": "string" - } - } - }, - "tasks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "apps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "families": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "label": {"type": "string"}, - "hideFilter": {"type": "boolean"} - }, - "required": ["name"] - } - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "color": {"type": "string"}, - "order": {"type": ["integer", "number"]} - }, - "required": ["name"] - } - }, - "copy": { - "type": "object" - } - } -} diff --git a/schema/config-1.1.json b/schema/config-1.1.json deleted file mode 100644 index 6e15514aaf..0000000000 --- a/schema/config-1.1.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.1", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": false, - "required": [ - "tasks", - "apps" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "template": { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^.*$": { - "type": "string" - } - } - }, - "tasks": { - "type": "object", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": [ - "short_name" - ] - } - }, - "apps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "families": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "label": {"type": "string"}, - "hideFilter": {"type": "boolean"} - }, - "required": ["name"] - } - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "color": {"type": "string"}, - "order": {"type": ["integer", "number"]} - }, - "required": ["name"] - } - }, - "copy": { - "type": "object" - } - } -} diff --git a/schema/config-2.0.json b/schema/config-2.0.json deleted file mode 100644 index 54b226711a..0000000000 --- a/schema/config-2.0.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-2.0", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": false, - "required": [ - "tasks", - "apps" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "templates": { - "type": "object" - }, - "roots": { - "type": "object" - }, - "imageio": { - "type": "object" - }, - "tasks": { - "type": "object", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": [ - "short_name" - ] - } - }, - "apps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "families": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "label": {"type": "string"}, - "hideFilter": {"type": "boolean"} - }, - "required": ["name"] - } - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "color": {"type": "string"}, - "order": {"type": ["integer", "number"]} - }, - "required": ["name"] - } - }, - "copy": { - "type": "object" - } - } -} diff --git a/schema/container-1.0.json b/schema/container-1.0.json deleted file mode 100644 index 012e8499e6..0000000000 --- a/schema/container-1.0.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:container-1.0", - "description": "A loaded asset", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "id", - "objectName", - "name", - "author", - "loader", - "families", - "time", - "subset", - "asset", - "representation", - "version", - "silo", - "path", - "source" - ], - "properties": { - "id": { - "description": "Identifier for finding object in host", - "type": "string", - "enum": ["pyblish.mindbender.container"], - "example": "pyblish.mindbender.container" - }, - "objectName": { - "description": "Name of internal object, such as the objectSet in Maya.", - "type": "string", - "example": "Bruce_:rigDefault_CON" - }, - "name": { - "description": "Full name of application object", - "type": "string", - "example": "modelDefault" - }, - "author": { - "description": "Name of the author of the published version", - "type": "string", - "example": "Marcus Ottosson" - }, - "loader": { - "description": "Name of loader plug-in used to produce this container", - "type": "string", - "example": "ModelLoader" - }, - "families": { - "description": "Families associated with the this subset", - "type": "string", - "example": "mindbender.model" - }, - "time": { - "description": "File-system safe, formatted time", - "type": "string", - "example": "20170329T131545Z" - }, - "subset": { - "description": "Name of source subset", - "type": "string", - "example": "modelDefault" - }, - "asset": { - "description": "Name of source asset", - "type": "string" , - "example": "Bruce" - }, - "representation": { - "description": "Name of source representation", - "type": "string" , - "example": ".ma" - }, - "version": { - "description": "Version number", - "type": "number", - "example": 12 - }, - "silo": { - "description": "Silo of parent asset", - "type": "string", - "example": "assets" - }, - "path": { - "description": "Absolute path on disk", - "type": "string", - "example": "{root}/assets/Bruce/publish/rigDefault/v002" - }, - "source": { - "description": "Absolute path to file from which this version was published", - "type": "string", - "example": "{root}/assets/Bruce/work/rigging/maya/scenes/rig_v001.ma" - } - } -} diff --git a/schema/container-2.0.json b/schema/container-2.0.json deleted file mode 100644 index 1673ee5d1d..0000000000 --- a/schema/container-2.0.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:container-2.0", - "description": "A loaded asset", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "id", - "objectName", - "name", - "namespace", - "loader", - "representation" - ], - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:container-2.0"], - "example": "openpype:container-2.0" - }, - "id": { - "description": "Identifier for finding object in host", - "type": "string", - "enum": ["pyblish.avalon.container"], - "example": "pyblish.avalon.container" - }, - "objectName": { - "description": "Name of internal object, such as the objectSet in Maya.", - "type": "string", - "example": "Bruce_:rigDefault_CON" - }, - "loader": { - "description": "Name of loader plug-in used to produce this container", - "type": "string", - "example": "ModelLoader" - }, - "name": { - "description": "Internal object name of container in application", - "type": "string", - "example": "modelDefault_01" - }, - "namespace": { - "description": "Internal namespace of container in application", - "type": "string", - "example": "Bruce_" - }, - "representation": { - "description": "Unique id of representation in database", - "type": "string", - "example": "59523f355f8c1b5f6c5e8348" - } - } -} diff --git a/schema/hero_version-1.0.json b/schema/hero_version-1.0.json deleted file mode 100644 index b720dc2887..0000000000 --- a/schema/hero_version-1.0.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:hero_version-1.0", - "description": "Hero version of asset", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "version_id", - "schema", - "type", - "parent" - ], - - "properties": { - "_id": { - "description": "Document's id (database will create it's if not entered)", - "example": "ObjectId(592c33475f8c1b064c4d1696)" - }, - "version_id": { - "description": "The version ID from which it was created", - "example": "ObjectId(592c33475f8c1b064c4d1695)" - }, - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:hero_version-1.0"], - "example": "openpype:hero_version-1.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["hero_version"], - "example": "hero_version" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "ObjectId(592c33475f8c1b064c4d1697)" - } - } -} diff --git a/schema/inventory-1.0.json b/schema/inventory-1.0.json deleted file mode 100644 index 2fe78794ab..0000000000 --- a/schema/inventory-1.0.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.0", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": true -} diff --git a/schema/inventory-1.1.json b/schema/inventory-1.1.json deleted file mode 100644 index b61a76b32a..0000000000 --- a/schema/inventory-1.1.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.1", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": true -} diff --git a/schema/project-2.0.json b/schema/project-2.0.json deleted file mode 100644 index 0ed5a55599..0000000000 --- a/schema/project-2.0.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:project-2.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data", - "config" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:project-2.0"], - "example": "openpype:project-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["project"], - "example": "project" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "hulk" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "fps": 24, - "width": 1920, - "height": 1080 - } - }, - "config": { - "type": "object", - "description": "Document metadata", - "example": { - "schema": "openpype:config-1.0", - "apps": [ - { - "name": "maya2016", - "label": "Autodesk Maya 2016" - }, - { - "name": "nuke10", - "label": "The Foundry Nuke 10.0" - } - ], - "tasks": [ - {"name": "model"}, - {"name": "render"}, - {"name": "animate"}, - {"name": "rig"}, - {"name": "lookdev"}, - {"name": "layout"} - ], - "template": { - "work": - "{root}/{project}/{silo}/{asset}/work/{task}/{app}", - "publish": - "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}" - } - }, - "$ref": "config-1.0.json" - } - }, - - "definitions": {} -} diff --git a/schema/project-2.1.json b/schema/project-2.1.json deleted file mode 100644 index 9413c9f691..0000000000 --- a/schema/project-2.1.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:project-2.1", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data", - "config" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:project-2.1"], - "example": "openpype:project-2.1" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["project"], - "example": "project" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "hulk" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "fps": 24, - "width": 1920, - "height": 1080 - } - }, - "config": { - "type": "object", - "description": "Document metadata", - "example": { - "schema": "openpype:config-1.1", - "apps": [ - { - "name": "maya2016", - "label": "Autodesk Maya 2016" - }, - { - "name": "nuke10", - "label": "The Foundry Nuke 10.0" - } - ], - "tasks": { - "Model": {"short_name": "mdl"}, - "Render": {"short_name": "rnd"}, - "Animate": {"short_name": "anim"}, - "Rig": {"short_name": "rig"}, - "Lookdev": {"short_name": "look"}, - "Layout": {"short_name": "lay"} - }, - "template": { - "work": - "{root}/{project}/{silo}/{asset}/work/{task}/{app}", - "publish": - "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}" - } - }, - "$ref": "config-1.1.json" - } - }, - - "definitions": {} -} diff --git a/schema/project-3.0.json b/schema/project-3.0.json deleted file mode 100644 index be23e10c93..0000000000 --- a/schema/project-3.0.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:project-3.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data", - "config" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:project-3.0"], - "example": "openpype:project-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["project"], - "example": "project" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "hulk" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "fps": 24, - "width": 1920, - "height": 1080 - } - }, - "config": { - "type": "object", - "description": "Document metadata", - "$ref": "config-2.0.json" - } - }, - - "definitions": {} -} diff --git a/schema/representation-1.0.json b/schema/representation-1.0.json deleted file mode 100644 index 347c585f52..0000000000 --- a/schema/representation-1.0.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:representation-1.0", - "description": "The inverse of an instance", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "format", - "path" - ], - - "properties": { - "schema": {"type": "string"}, - "format": { - "description": "File extension, including '.'", - "type": "string" - }, - "path": { - "description": "Unformatted path to version.", - "type": "string" - } - } -} diff --git a/schema/representation-2.0.json b/schema/representation-2.0.json deleted file mode 100644 index f47c16a10a..0000000000 --- a/schema/representation-2.0.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:representation-2.0", - "description": "The inverse of an instance", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:representation-2.0"], - "example": "openpype:representation-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["representation"], - "example": "representation" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of representation", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "abc" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "label": "Alembic" - } - }, - "dependencies": { - "description": "Other representation that this representation depends on", - "type": "array", - "items": {"type": "string"}, - "example": [ - "592d547a5f8c1b388093c145" - ] - }, - "context": { - "description": "Summary of the context to which this representation belong.", - "type": "object", - "properties": { - "project": {"type": "object"}, - "asset": {"type": "string"}, - "silo": {"type": ["string", "null"]}, - "subset": {"type": "string"}, - "version": {"type": "number"}, - "representation": {"type": "string"} - }, - "example": { - "project": "hulk", - "asset": "Bruce", - "silo": "assets", - "subset": "rigDefault", - "version": 12, - "representation": "ma" - } - } - } -} diff --git a/schema/session-1.0.json b/schema/session-1.0.json deleted file mode 100644 index 5ced0a6f08..0000000000 --- a/schema/session-1.0.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:session-1.0", - "description": "The Avalon environment", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "AVALON_PROJECTS", - "AVALON_PROJECT", - "AVALON_ASSET", - "AVALON_SILO", - "AVALON_CONFIG" - ], - - "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, - "AVALON_PROJECT": { - "description": "Name of project", - "type": "string", - "pattern": "^\\w*$", - "example": "Hulk" - }, - "AVALON_ASSET": { - "description": "Name of asset", - "type": "string", - "pattern": "^\\w*$", - "example": "Bruce" - }, - "AVALON_SILO": { - "description": "Name of asset group or container", - "type": "string", - "pattern": "^\\w*$", - "example": "assets" - }, - "AVALON_TASK": { - "description": "Name of task", - "type": "string", - "pattern": "^\\w*$", - "example": "modeling" - }, - "AVALON_CONFIG": { - "description": "Name of Avalon configuration", - "type": "string", - "pattern": "^\\w*$", - "example": "polly" - }, - "AVALON_APP": { - "description": "Name of application", - "type": "string", - "pattern": "^\\w*$", - "example": "maya2016" - }, - "AVALON_MONGO": { - "description": "Address to the asset database", - "type": "string", - "pattern": "^mongodb://[\\w/@:.]*$", - "example": "mongodb://localhost:27017", - "default": "mongodb://localhost:27017" - }, - "AVALON_DB": { - "description": "Name of database", - "type": "string", - "pattern": "^\\w*$", - "example": "avalon", - "default": "avalon" - }, - "AVALON_LABEL": { - "description": "Nice name of Avalon, used in e.g. graphical user interfaces", - "type": "string", - "example": "Mindbender", - "default": "Avalon" - }, - "AVALON_SENTRY": { - "description": "Address to Sentry", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2", - "default": null - }, - "AVALON_DEADLINE": { - "description": "Address to Deadline", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "http://192.168.99.101", - "default": null - }, - "AVALON_TIMEOUT": { - "description": "Wherever there is a need for a timeout, this is the default value.", - "type": "string", - "pattern": "^[0-9]*$", - "default": "1000", - "example": "1000" - }, - "AVALON_UPLOAD": { - "description": "Boolean of whether to upload published material to central asset repository", - "type": "string", - "default": null, - "example": "True" - }, - "AVALON_USERNAME": { - "description": "Generic username", - "type": "string", - "pattern": "^\\w*$", - "default": "avalon", - "example": "myself" - }, - "AVALON_PASSWORD": { - "description": "Generic password", - "type": "string", - "pattern": "^\\w*$", - "default": "secret", - "example": "abc123" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" - }, - "AVALON_DEBUG": { - "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.", - "type": "string", - "default": null, - "example": "True" - } - } -} diff --git a/schema/session-2.0.json b/schema/session-2.0.json deleted file mode 100644 index 0a4d51beb2..0000000000 --- a/schema/session-2.0.json +++ /dev/null @@ -1,134 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:session-2.0", - "description": "The Avalon environment", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "AVALON_PROJECT", - "AVALON_ASSET", - "AVALON_CONFIG" - ], - - "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, - "AVALON_PROJECT": { - "description": "Name of project", - "type": "string", - "pattern": "^\\w*$", - "example": "Hulk" - }, - "AVALON_ASSET": { - "description": "Name of asset", - "type": "string", - "pattern": "^\\w*$", - "example": "Bruce" - }, - "AVALON_SILO": { - "description": "Name of asset group or container", - "type": "string", - "pattern": "^\\w*$", - "example": "assets" - }, - "AVALON_TASK": { - "description": "Name of task", - "type": "string", - "pattern": "^\\w*$", - "example": "modeling" - }, - "AVALON_CONFIG": { - "description": "Name of Avalon configuration", - "type": "string", - "pattern": "^\\w*$", - "example": "polly" - }, - "AVALON_APP": { - "description": "Name of application", - "type": "string", - "pattern": "^\\w*$", - "example": "maya2016" - }, - "AVALON_DB": { - "description": "Name of database", - "type": "string", - "pattern": "^\\w*$", - "example": "avalon", - "default": "avalon" - }, - "AVALON_LABEL": { - "description": "Nice name of Avalon, used in e.g. graphical user interfaces", - "type": "string", - "example": "Mindbender", - "default": "Avalon" - }, - "AVALON_SENTRY": { - "description": "Address to Sentry", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2", - "default": null - }, - "AVALON_DEADLINE": { - "description": "Address to Deadline", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "http://192.168.99.101", - "default": null - }, - "AVALON_TIMEOUT": { - "description": "Wherever there is a need for a timeout, this is the default value.", - "type": "string", - "pattern": "^[0-9]*$", - "default": "1000", - "example": "1000" - }, - "AVALON_UPLOAD": { - "description": "Boolean of whether to upload published material to central asset repository", - "type": "string", - "default": null, - "example": "True" - }, - "AVALON_USERNAME": { - "description": "Generic username", - "type": "string", - "pattern": "^\\w*$", - "default": "avalon", - "example": "myself" - }, - "AVALON_PASSWORD": { - "description": "Generic password", - "type": "string", - "pattern": "^\\w*$", - "default": "secret", - "example": "abc123" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" - }, - "AVALON_DEBUG": { - "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.", - "type": "string", - "default": null, - "example": "True" - } - } -} diff --git a/schema/session-3.0.json b/schema/session-3.0.json deleted file mode 100644 index 9f785939e4..0000000000 --- a/schema/session-3.0.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:session-3.0", - "description": "The Avalon environment", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "AVALON_PROJECT", - "AVALON_ASSET" - ], - - "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, - "AVALON_PROJECT": { - "description": "Name of project", - "type": "string", - "pattern": "^\\w*$", - "example": "Hulk" - }, - "AVALON_ASSET": { - "description": "Name of asset", - "type": "string", - "pattern": "^\\w*$", - "example": "Bruce" - }, - "AVALON_TASK": { - "description": "Name of task", - "type": "string", - "pattern": "^\\w*$", - "example": "modeling" - }, - "AVALON_APP": { - "description": "Name of host", - "type": "string", - "pattern": "^\\w*$", - "example": "maya2016" - }, - "AVALON_DB": { - "description": "Name of database", - "type": "string", - "pattern": "^\\w*$", - "example": "avalon", - "default": "avalon" - }, - "AVALON_LABEL": { - "description": "Nice name of Avalon, used in e.g. graphical user interfaces", - "type": "string", - "example": "Mindbender", - "default": "Avalon" - }, - "AVALON_TIMEOUT": { - "description": "Wherever there is a need for a timeout, this is the default value.", - "type": "string", - "pattern": "^[0-9]*$", - "default": "1000", - "example": "1000" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" - } - } -} diff --git a/schema/shaders-1.0.json b/schema/shaders-1.0.json deleted file mode 100644 index 7102ba1861..0000000000 --- a/schema/shaders-1.0.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:shaders-1.0", - "description": "Relationships between shaders and Avalon IDs", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "shader" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "shader": { - "description": "Name of directory", - "type": "array", - "items": { - "type": "str", - "description": "Avalon ID and optional face indexes, e.g. 'f9520572-ac1d-11e6-b39e-3085a99791c9.f[5002:5185]'" - } - } - }, - - "definitions": {} -} diff --git a/schema/subset-1.0.json b/schema/subset-1.0.json deleted file mode 100644 index a299a6d341..0000000000 --- a/schema/subset-1.0.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:subset-1.0", - "description": "A container of instances", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "name", - "versions" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "name": { - "description": "Name of directory", - "type": "string" - }, - "versions": { - "type": "array", - "items": { - "$ref": "version.json" - } - } - }, - - "definitions": {} -} diff --git a/schema/subset-2.0.json b/schema/subset-2.0.json deleted file mode 100644 index db256ec7fb..0000000000 --- a/schema/subset-2.0.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:subset-2.0", - "description": "A container of instances", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:subset-2.0"], - "example": "openpype:subset-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["subset"], - "example": "subset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "shot01" - }, - "data": { - "type": "object", - "description": "Document metadata", - "example": { - "frameStart": 1000, - "frameEnd": 1201 - } - } - } -} diff --git a/schema/subset-3.0.json b/schema/subset-3.0.json deleted file mode 100644 index 1a0db53c04..0000000000 --- a/schema/subset-3.0.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:subset-3.0", - "description": "A container of instances", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:subset-3.0"], - "example": "openpype:subset-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["subset"], - "example": "subset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "shot01" - }, - "data": { - "description": "Document metadata", - "type": "object", - "required": ["families"], - "properties": { - "families": { - "type": "array", - "items": {"type": "string"}, - "description": "One or more families associated with this subset" - } - }, - "example": { - "families" : [ - "avalon.camera" - ], - "frameStart": 1000, - "frameEnd": 1201 - } - } - } -} diff --git a/schema/thumbnail-1.0.json b/schema/thumbnail-1.0.json deleted file mode 100644 index 5bdf78a4b1..0000000000 --- a/schema/thumbnail-1.0.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:thumbnail-1.0", - "description": "Entity with thumbnail data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:thumbnail-1.0"], - "example": "openpype:thumbnail-1.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["thumbnail"], - "example": "thumbnail" - }, - "data": { - "description": "Thumbnail data", - "type": "object", - "example": { - "binary_data": "Binary({byte data of image})", - "template": "{thumbnail_root}/{project[name]}/{_id}{ext}}", - "template_data": { - "ext": ".jpg" - } - } - } - } -} diff --git a/schema/version-1.0.json b/schema/version-1.0.json deleted file mode 100644 index daa1997721..0000000000 --- a/schema/version-1.0.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:version-1.0", - "description": "An individual version", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "version", - "path", - "time", - "author", - "source", - "representations" - ], - - "properties": { - "schema": {"type": "string"}, - "representations": { - "type": "array", - "items": { - "$ref": "representation.json" - } - }, - "time": { - "description": "ISO formatted, file-system compatible time", - "type": "string" - }, - "author": { - "description": "User logged on to the machine at time of publish", - "type": "string" - }, - "version": { - "description": "Number of this version", - "type": "number" - }, - "path": { - "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001", - "type": "string" - }, - "source": { - "description": "Original file from which this version was made.", - "type": "string" - } - } -} diff --git a/schema/version-2.0.json b/schema/version-2.0.json deleted file mode 100644 index 099e9be70a..0000000000 --- a/schema/version-2.0.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:version-2.0", - "description": "An individual version", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:version-2.0"], - "example": "openpype:version-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["version"], - "example": "version" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Number of version", - "type": "number", - "example": 12 - }, - "locations": { - "description": "Where on the planet this version can be found.", - "type": "array", - "items": {"type": "string"}, - "example": ["data.avalon.com"] - }, - "data": { - "description": "Document metadata", - "type": "object", - "required": ["families", "author", "source", "time"], - "properties": { - "time": { - "description": "ISO formatted, file-system compatible time", - "type": "string" - }, - "timeFormat": { - "description": "ISO format of time", - "type": "string" - }, - "author": { - "description": "User logged on to the machine at time of publish", - "type": "string" - }, - "version": { - "description": "Number of this version", - "type": "number" - }, - "path": { - "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001", - "type": "string" - }, - "source": { - "description": "Original file from which this version was made.", - "type": "string" - }, - "families": { - "type": "array", - "items": {"type": "string"}, - "description": "One or more families associated with this version" - } - }, - "example": { - "source" : "{root}/f02_prod/assets/BubbleWitch/work/modeling/marcus/maya/scenes/model_v001.ma", - "author" : "marcus", - "families" : [ - "avalon.model" - ], - "time" : "20170510T090203Z" - } - } - } -} diff --git a/schema/version-3.0.json b/schema/version-3.0.json deleted file mode 100644 index 3e07fc4499..0000000000 --- a/schema/version-3.0.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:version-3.0", - "description": "An individual version", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:version-3.0"], - "example": "openpype:version-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["version"], - "example": "version" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Number of version", - "type": "number", - "example": 12 - }, - "locations": { - "description": "Where on the planet this version can be found.", - "type": "array", - "items": {"type": "string"}, - "example": ["data.avalon.com"] - }, - "data": { - "description": "Document metadata", - "type": "object", - "required": ["author", "source", "time"], - "properties": { - "time": { - "description": "ISO formatted, file-system compatible time", - "type": "string" - }, - "timeFormat": { - "description": "ISO format of time", - "type": "string" - }, - "author": { - "description": "User logged on to the machine at time of publish", - "type": "string" - }, - "version": { - "description": "Number of this version", - "type": "number" - }, - "path": { - "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001", - "type": "string" - }, - "source": { - "description": "Original file from which this version was made.", - "type": "string" - } - }, - "example": { - "source" : "{root}/f02_prod/assets/BubbleWitch/work/modeling/marcus/maya/scenes/model_v001.ma", - "author" : "marcus", - "time" : "20170510T090203Z" - } - } - } -} diff --git a/schema/workfile-1.0.json b/schema/workfile-1.0.json deleted file mode 100644 index 5f9600ef20..0000000000 --- a/schema/workfile-1.0.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:workfile-1.0", - "description": "Workfile additional information.", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "filename", - "task_name", - "parent" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:workfile-1.0"], - "example": "openpype:workfile-1.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["workfile"], - "example": "workfile" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "filename": { - "description": "Workfile's filename", - "type": "string", - "example": "kuba_each_case_Alpaca_01_animation_v001.ma" - }, - "task_name": { - "description": "Task name", - "type": "string", - "example": "animation" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": {"key": "value"} - } - } -} diff --git a/setup.py b/setup.py index 4b6f286730..6179de1d34 100644 --- a/setup.py +++ b/setup.py @@ -124,7 +124,6 @@ bin_includes = [ include_files = [ "igniter", "openpype", - "schema", "LICENSE", "README.md" ] From 975d09d66868d76dad1eb356539389700eb35c99 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 30 Aug 2023 14:29:16 +0200 Subject: [PATCH 417/429] lower urllib3 to support older OpenSSL than 1.1.1 --- poetry.lock | 15 +++++++-------- pyproject.toml | 1 + 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5621d39988..5e88f5b93c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3372,21 +3372,20 @@ files = [ [[package]] name = "urllib3" -version = "2.0.2" +version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, - {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, + {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"}, + {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" diff --git a/pyproject.toml b/pyproject.toml index b394a895e3..e2d35f3c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ QtPy = "^2.3.0" qtawesome = "0.7.3" speedcopy = "^2.1" six = "^1.15" +urllib3 = "1.26.16" semver = "^2.13.0" # for version resolution wsrpc_aiohttp = "^3.1.1" # websocket server pywin32 = { version = "301", markers = "sys_platform == 'win32'" } From b1796d6603c3ff74aaf9798e14f9b4e08e0c9c3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 11:19:33 +0200 Subject: [PATCH 418/429] do not add schema to zip files --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 408764e1a8..e7b440f812 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -589,7 +589,7 @@ class BootstrapRepos: self.registry = OpenPypeSettingsRegistry() self.zip_filter = [".pyc", "__pycache__"] self.openpype_filter = [ - "openpype", "schema", "LICENSE" + "openpype", "LICENSE" ] # dummy progress reporter From b4aa3b15dfbac86834fdf6dad796672634802363 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:31:44 +0200 Subject: [PATCH 419/429] do not override scale factor rounding policy if has defined value through env variable --- igniter/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index 16ffb940f6..085a825860 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -34,7 +34,11 @@ def _get_qt_app(): if attr is not None: QtWidgets.QApplication.setAttribute(attr) - if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"): + policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY") + if ( + hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy") + and not policy + ): QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) From 3e9528b5e06e9de103f0b71f1ab1d063635083e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jul 2023 23:41:32 +0000 Subject: [PATCH 420/429] Bump certifi from 2023.5.7 to 2023.7.22 Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.5.7 to 2023.7.22. - [Commits](https://github.com/certifi/python-certifi/compare/2023.05.07...2023.07.22) --- updated-dependencies: - dependency-name: certifi dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 178 ++-------------------------------------------------- 1 file changed, 6 insertions(+), 172 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5e88f5b93c..2b9fab4bc4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "acre" version = "1.0.0" description = "Lightweight cross-platform environment management Python package that makes it trivial to launch applications in their own configurable working environment." -category = "main" optional = false python-versions = ">=2.7" files = [] @@ -20,7 +19,6 @@ resolved_reference = "126f7a188cfe36718f707f42ebbc597e86aa86c3" name = "aiohttp" version = "3.8.4" description = "Async http client/server framework (asyncio)" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -129,7 +127,6 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiohttp-json-rpc" version = "0.13.3" description = "Implementation JSON-RPC 2.0 server and client using aiohttp on top of websockets transport" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -144,7 +141,6 @@ aiohttp = ">=3,<4" name = "aiohttp-middlewares" version = "2.2.0" description = "Collection of useful middlewares for aiohttp applications." -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -161,7 +157,6 @@ yarl = ">=1.5.1,<2.0.0" name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -176,7 +171,6 @@ frozenlist = ">=1.1.0" name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -188,7 +182,6 @@ files = [ name = "ansicon" version = "1.89.0" description = "Python wrapper for loading Jason Hood's ANSICON" -category = "main" optional = false python-versions = "*" files = [ @@ -200,7 +193,6 @@ files = [ name = "appdirs" version = "1.4.4" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [] @@ -216,7 +208,6 @@ resolved_reference = "8734277956c1df3b85385e6b308e954910533884" name = "arrow" version = "0.17.0" description = "Better dates & times for Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -231,7 +222,6 @@ python-dateutil = ">=2.7.0" name = "astroid" version = "2.15.5" description = "An abstract syntax tree for Python with inference support." -category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -248,7 +238,6 @@ wrapt = {version = ">=1.11,<2", markers = "python_version < \"3.11\""} name = "async-timeout" version = "4.0.2" description = "Timeout context manager for asyncio programs" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -260,7 +249,6 @@ files = [ name = "atomicwrites" version = "1.4.1" description = "Atomic file writes." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -271,7 +259,6 @@ files = [ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -290,7 +277,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "autopep8" version = "2.0.2" description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -306,7 +292,6 @@ tomli = {version = "*", markers = "python_version < \"3.11\""} name = "babel" version = "2.12.1" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -318,7 +303,6 @@ files = [ name = "bcrypt" version = "4.0.1" description = "Modern password hashing for your software and your servers" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -353,7 +337,6 @@ typecheck = ["mypy"] name = "bidict" version = "0.22.1" description = "The bidirectional mapping library for Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -370,7 +353,6 @@ test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "py name = "blessed" version = "1.20.0" description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." -category = "main" optional = false python-versions = ">=2.7" files = [ @@ -387,7 +369,6 @@ wcwidth = ">=0.1.4" name = "cachetools" version = "5.3.1" description = "Extensible memoizing collections and decorators" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -397,21 +378,19 @@ files = [ [[package]] name = "certifi" -version = "2023.5.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] name = "cffi" version = "1.15.1" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false python-versions = "*" files = [ @@ -488,7 +467,6 @@ pycparser = "*" name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -500,7 +478,6 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -585,7 +562,6 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -600,7 +576,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "clique" version = "1.6.1" description = "Manage collections with common numerical component" -category = "main" optional = false python-versions = ">=2.7, <4.0" files = [ @@ -617,7 +592,6 @@ test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)" name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -629,7 +603,6 @@ files = [ name = "commonmark" version = "0.9.1" description = "Python parser for the CommonMark Markdown spec" -category = "dev" optional = false python-versions = "*" files = [ @@ -644,7 +617,6 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] name = "coolname" version = "2.2.0" description = "Random name and slug generator" -category = "main" optional = false python-versions = "*" files = [ @@ -656,7 +628,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -732,7 +703,6 @@ toml = ["tomli"] name = "cryptography" version = "39.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -776,7 +746,6 @@ test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0 name = "cx-freeze" version = "6.12.0" description = "Create standalone executables from Python scripts" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -834,7 +803,6 @@ test = ["nose (==1.3.7)", "pygments (>=2.11.2)", "pytest (>=7.0.1)", "pytest-cov name = "cx-logging" version = "3.1.0" description = "Python and C interfaces for logging" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -865,7 +833,6 @@ files = [ name = "dill" version = "0.3.6" description = "serialize all of python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -880,7 +847,6 @@ graph = ["objgraph (>=1.7.2)"] name = "distlib" version = "0.3.6" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -892,7 +858,6 @@ files = [ name = "dnspython" version = "2.3.0" description = "DNS toolkit" -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -913,7 +878,6 @@ wmi = ["wmi (>=1.5.1,<2.0.0)"] name = "docutils" version = "0.19" description = "Docutils -- Python Documentation Utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -925,7 +889,6 @@ files = [ name = "dropbox" version = "11.36.0" description = "Official Dropbox API Client" -category = "main" optional = false python-versions = "*" files = [ @@ -943,7 +906,6 @@ stone = ">=2" name = "enlighten" version = "1.11.2" description = "Enlighten Progress Bar" -category = "main" optional = false python-versions = "*" files = [ @@ -959,7 +921,6 @@ prefixed = ">=0.3.2" name = "evdev" version = "1.6.1" description = "Bindings to the Linux input handling subsystem" -category = "main" optional = false python-versions = "*" files = [ @@ -970,7 +931,6 @@ files = [ name = "filelock" version = "3.12.0" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -986,7 +946,6 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "p name = "flake8" version = "6.0.0" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = ">=3.8.1" files = [ @@ -1003,7 +962,6 @@ pyflakes = ">=3.0.0,<3.1.0" name = "frozenlist" version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1087,7 +1045,6 @@ files = [ name = "ftrack-python-api" version = "2.5.0" description = "Python API for ftrack." -category = "main" optional = false python-versions = ">=2.7.9, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -1110,7 +1067,6 @@ websocket-client = ">=0.40.0,<1" name = "future" version = "0.18.3" description = "Clean single-source support for Python 3 and 2" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1121,7 +1077,6 @@ files = [ name = "gazu" version = "0.9.3" description = "Gazu is a client for Zou, the API to store the data of your CG production." -category = "main" optional = false python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*, != 3.5.*, != 3.6.1, != 3.6.2" files = [ @@ -1141,7 +1096,6 @@ test = ["pytest", "pytest-cov", "requests-mock"] name = "gitdb" version = "4.0.10" description = "Git Object Database" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1156,7 +1110,6 @@ smmap = ">=3.0.1,<6" name = "gitpython" version = "3.1.31" description = "GitPython is a Python library used to interact with Git repositories" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1171,7 +1124,6 @@ gitdb = ">=4.0.1,<5" name = "google-api-core" version = "2.11.0" description = "Google API client core library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1194,7 +1146,6 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0dev)"] name = "google-api-python-client" version = "1.12.11" description = "Google API Client Library for Python" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" files = [ @@ -1214,7 +1165,6 @@ uritemplate = ">=3.0.0,<4dev" name = "google-auth" version = "2.17.3" description = "Google Authentication Library" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" files = [ @@ -1239,7 +1189,6 @@ requests = ["requests (>=2.20.0,<3.0.0dev)"] name = "google-auth-httplib2" version = "0.1.0" description = "Google Authentication Library: httplib2 transport" -category = "main" optional = false python-versions = "*" files = [ @@ -1256,7 +1205,6 @@ six = "*" name = "googleapis-common-protos" version = "1.59.0" description = "Common protobufs used in Google APIs" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1274,7 +1222,6 @@ grpc = ["grpcio (>=1.44.0,<2.0.0dev)"] name = "httplib2" version = "0.22.0" description = "A comprehensive HTTP client library." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1289,7 +1236,6 @@ pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0 name = "identify" version = "2.5.24" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1304,7 +1250,6 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -1316,7 +1261,6 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1328,7 +1272,6 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1348,7 +1291,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1360,7 +1302,6 @@ files = [ name = "isort" version = "5.12.0" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -1378,7 +1319,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "jedi" version = "0.13.3" description = "An autocompletion tool for Python that can be used for text editors." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1396,7 +1336,6 @@ testing = ["colorama", "docopt", "pytest (>=3.1.0)"] name = "jeepney" version = "0.8.0" description = "Low-level, pure Python DBus protocol wrapper." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1412,7 +1351,6 @@ trio = ["async_generator", "trio"] name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1430,7 +1368,6 @@ i18n = ["Babel (>=2.7)"] name = "jinxed" version = "1.2.0" description = "Jinxed Terminal Library" -category = "main" optional = false python-versions = "*" files = [ @@ -1445,7 +1382,6 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""} name = "jsonschema" version = "2.6.0" description = "An implementation of JSON Schema validation for Python" -category = "main" optional = false python-versions = "*" files = [ @@ -1460,7 +1396,6 @@ format = ["rfc3987", "strict-rfc3339", "webcolors"] name = "keyring" version = "22.4.0" description = "Store and access your passwords safely." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1482,7 +1417,6 @@ testing = ["pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2. name = "lazy-object-proxy" version = "1.9.0" description = "A fast and thorough lazy object proxy." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1528,7 +1462,6 @@ files = [ name = "lief" version = "0.13.1" description = "Library to instrument executable formats" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1561,7 +1494,6 @@ files = [ name = "linkify-it-py" version = "2.0.2" description = "Links recognition library with FULL unicode support." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1582,7 +1514,6 @@ test = ["coverage", "pytest", "pytest-cov"] name = "log4mongo" version = "1.7.0" description = "mongo database handler for python logging" -category = "main" optional = false python-versions = "*" files = [ @@ -1596,7 +1527,6 @@ pymongo = "*" name = "m2r2" version = "0.3.3.post2" description = "Markdown and reStructuredText in a single file." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1612,7 +1542,6 @@ mistune = "0.8.4" name = "markdown-it-py" version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1637,7 +1566,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1716,7 +1644,6 @@ files = [ name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1728,7 +1655,6 @@ files = [ name = "mdit-py-plugins" version = "0.3.5" description = "Collection of plugins for markdown-it-py" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1748,7 +1674,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1760,7 +1685,6 @@ files = [ name = "mistune" version = "0.8.4" description = "The fastest markdown parser in pure Python" -category = "dev" optional = false python-versions = "*" files = [ @@ -1772,7 +1696,6 @@ files = [ name = "multidict" version = "6.0.4" description = "multidict implementation" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1856,7 +1779,6 @@ files = [ name = "myst-parser" version = "0.18.1" description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1883,7 +1805,6 @@ testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -1898,7 +1819,6 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1910,7 +1830,6 @@ files = [ name = "paramiko" version = "3.2.0" description = "SSH2 protocol library" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1932,7 +1851,6 @@ invoke = ["invoke (>=2.0)"] name = "parso" version = "0.8.3" description = "A Python Parser" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1948,7 +1866,6 @@ testing = ["docopt", "pytest (<6.0.0)"] name = "patchelf" version = "0.17.2.1" description = "A small utility to modify the dynamic linker and RPATH of ELF executables." -category = "dev" optional = false python-versions = "*" files = [ @@ -1968,7 +1885,6 @@ test = ["importlib-metadata", "pytest"] name = "pathlib2" version = "2.3.7.post1" description = "Object-oriented filesystem paths" -category = "main" optional = false python-versions = "*" files = [ @@ -1983,7 +1899,6 @@ six = "*" name = "pillow" version = "9.5.0" description = "Python Imaging Library (Fork)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2063,7 +1978,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa name = "platformdirs" version = "3.5.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2079,7 +1993,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2095,7 +2008,6 @@ testing = ["pytest", "pytest-benchmark"] name = "ply" version = "3.11" description = "Python Lex & Yacc" -category = "main" optional = false python-versions = "*" files = [ @@ -2107,7 +2019,6 @@ files = [ name = "pockets" version = "0.9.1" description = "A collection of helpful Python tools!" -category = "dev" optional = false python-versions = "*" files = [ @@ -2122,7 +2033,6 @@ six = ">=1.5.2" name = "pre-commit" version = "3.3.2" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2141,7 +2051,6 @@ virtualenv = ">=20.10.0" name = "prefixed" version = "0.7.0" description = "Prefixed alternative numeric library" -category = "main" optional = false python-versions = "*" files = [ @@ -2153,7 +2062,6 @@ files = [ name = "protobuf" version = "4.23.2" description = "" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2176,7 +2084,6 @@ files = [ name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -2188,7 +2095,6 @@ files = [ name = "pyasn1" version = "0.5.0" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -2200,7 +2106,6 @@ files = [ name = "pyasn1-modules" version = "0.3.0" description = "A collection of ASN.1-based protocols modules" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -2215,7 +2120,6 @@ pyasn1 = ">=0.4.6,<0.6.0" name = "pyblish-base" version = "1.8.11" description = "Plug-in driven automation framework for content" -category = "main" optional = false python-versions = "*" files = [ @@ -2227,7 +2131,6 @@ files = [ name = "pycodestyle" version = "2.10.0" description = "Python style guide checker" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2239,7 +2142,6 @@ files = [ name = "pycparser" version = "2.21" description = "C parser in Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -2251,7 +2153,6 @@ files = [ name = "pydocstyle" version = "6.3.0" description = "Python docstring style checker" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2269,7 +2170,6 @@ toml = ["tomli (>=1.2.3)"] name = "pyflakes" version = "3.0.1" description = "passive checker of Python programs" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2281,7 +2181,6 @@ files = [ name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2296,7 +2195,6 @@ plugins = ["importlib-metadata"] name = "pylint" version = "2.17.4" description = "python code static checker" -category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -2323,7 +2221,6 @@ testutils = ["gitpython (>3)"] name = "pymongo" version = "3.13.0" description = "Python driver for MongoDB " -category = "main" optional = false python-versions = "*" files = [ @@ -2452,7 +2349,6 @@ zstd = ["zstandard"] name = "pynacl" version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2479,7 +2375,6 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] name = "pynput" version = "1.7.6" description = "Monitor and control user input devices" -category = "main" optional = false python-versions = "*" files = [ @@ -2498,7 +2393,6 @@ six = "*" name = "pyobjc-core" version = "9.1.1" description = "Python<->ObjC Interoperability Module" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2515,7 +2409,6 @@ files = [ name = "pyobjc-framework-applicationservices" version = "9.1.1" description = "Wrappers for the framework ApplicationServices on macOS" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2537,7 +2430,6 @@ pyobjc-framework-Quartz = ">=9.1.1" name = "pyobjc-framework-cocoa" version = "9.1.1" description = "Wrappers for the Cocoa frameworks on macOS" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2557,7 +2449,6 @@ pyobjc-core = ">=9.1.1" name = "pyobjc-framework-quartz" version = "9.1.1" description = "Wrappers for the Quartz frameworks on macOS" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2578,7 +2469,6 @@ pyobjc-framework-Cocoa = ">=9.1.1" name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2590,7 +2480,6 @@ files = [ name = "pysftp" version = "0.2.9" description = "A friendly face on SFTP" -category = "main" optional = false python-versions = "*" files = [ @@ -2604,7 +2493,6 @@ paramiko = ">=1.17" name = "pytest" version = "6.2.5" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2629,7 +2517,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2648,7 +2535,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-print" version = "0.3.1" description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2666,7 +2552,6 @@ test = ["coverage (>=5)"] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -2681,7 +2566,6 @@ six = ">=1.5" name = "python-engineio" version = "4.4.1" description = "Engine.IO server and client for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2697,7 +2581,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] name = "python-socketio" version = "5.8.0" description = "Socket.IO server and client for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2719,7 +2602,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] name = "python-xlib" version = "0.33" description = "Python X Library" -category = "main" optional = false python-versions = "*" files = [ @@ -2734,7 +2616,6 @@ six = ">=1.10.0" name = "python3-xlib" version = "0.15" description = "Python3 X Library" -category = "main" optional = false python-versions = "*" files = [ @@ -2745,7 +2626,6 @@ files = [ name = "pywin32" version = "301" description = "Python for Window Extensions" -category = "main" optional = false python-versions = "*" files = [ @@ -2765,7 +2645,6 @@ files = [ name = "pywin32-ctypes" version = "0.2.0" description = "" -category = "main" optional = false python-versions = "*" files = [ @@ -2777,7 +2656,6 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2827,7 +2705,6 @@ files = [ name = "qt-py" version = "1.3.8" description = "Python 2 & 3 compatibility wrapper around all Qt bindings - PySide, PySide2, PyQt4 and PyQt5." -category = "main" optional = false python-versions = "*" files = [ @@ -2842,7 +2719,6 @@ types-PySide2 = "*" name = "qtawesome" version = "0.7.3" description = "FontAwesome icons in PyQt and PySide applications" -category = "main" optional = false python-versions = "*" files = [ @@ -2858,7 +2734,6 @@ six = "*" name = "qtpy" version = "2.3.1" description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2876,7 +2751,6 @@ test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"] name = "recommonmark" version = "0.7.1" description = "A docutils-compatibility bridge to CommonMark, enabling you to write CommonMark inside of Docutils & Sphinx projects." -category = "dev" optional = false python-versions = "*" files = [ @@ -2893,7 +2767,6 @@ sphinx = ">=1.3.1" name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2915,7 +2788,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "revitron-sphinx-theme" version = "0.7.2" description = "Revitron theme for Sphinx" -category = "dev" optional = false python-versions = "*" files = [] @@ -2937,7 +2809,6 @@ resolved_reference = "c0779c66365d9d258d93575ebaff7db9d3aee282" name = "rsa" version = "4.9" description = "Pure-Python RSA implementation" -category = "main" optional = false python-versions = ">=3.6,<4" files = [ @@ -2952,7 +2823,6 @@ pyasn1 = ">=0.1.3" name = "secretstorage" version = "3.3.3" description = "Python bindings to FreeDesktop.org Secret Service API" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2968,7 +2838,6 @@ jeepney = ">=0.6" name = "semver" version = "2.13.0" description = "Python helper for Semantic Versioning (http://semver.org/)" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -2980,7 +2849,6 @@ files = [ name = "setuptools" version = "65.7.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2997,7 +2865,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "shotgun-api3" version = "3.3.3" description = "Shotgun Python API" -category = "main" optional = false python-versions = "*" files = [] @@ -3013,7 +2880,6 @@ resolved_reference = "b9f066c0edbea6e0733242e18f32f75489064840" name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -3025,7 +2891,6 @@ files = [ name = "slack-sdk" version = "3.21.3" description = "The Slack API Platform SDK for Python" -category = "main" optional = false python-versions = ">=3.6.0" files = [ @@ -3041,7 +2906,6 @@ testing = ["Flask (>=1,<2)", "Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "We name = "smmap" version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -3053,7 +2917,6 @@ files = [ name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" optional = false python-versions = "*" files = [ @@ -3065,7 +2928,6 @@ files = [ name = "speedcopy" version = "2.1.4" description = "Replacement or alternative for python copyfile() utilizing server side copy on network shares for faster copying." -category = "main" optional = false python-versions = "*" files = [ @@ -3077,7 +2939,6 @@ files = [ name = "sphinx" version = "5.3.0" description = "Python documentation generator" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -3113,7 +2974,6 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] name = "sphinx-autoapi" version = "2.1.0" description = "Sphinx API documentation generator" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3137,7 +2997,6 @@ go = ["sphinxcontrib-golangdomain"] name = "sphinxcontrib-applehelp" version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -3153,7 +3012,6 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3169,7 +3027,6 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -3185,7 +3042,6 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3200,7 +3056,6 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-napoleon" version = "0.7" description = "Sphinx \"napoleon\" extension." -category = "dev" optional = false python-versions = "*" files = [ @@ -3216,7 +3071,6 @@ six = ">=1.5.2" name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3232,7 +3086,6 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3248,7 +3101,6 @@ test = ["pytest"] name = "stone" version = "3.3.1" description = "Stone is an interface description language (IDL) for APIs." -category = "main" optional = false python-versions = "*" files = [ @@ -3265,7 +3117,6 @@ six = ">=1.12.0" name = "termcolor" version = "1.1.0" description = "ANSII Color formatting for output in terminal." -category = "main" optional = false python-versions = "*" files = [ @@ -3276,7 +3127,6 @@ files = [ name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -3288,7 +3138,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3300,7 +3149,6 @@ files = [ name = "tomlkit" version = "0.11.8" description = "Style preserving TOML library" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3312,7 +3160,6 @@ files = [ name = "types-pyside2" version = "5.15.2.1.5" description = "The most accurate stubs for PySide2" -category = "main" optional = false python-versions = "*" files = [ @@ -3323,7 +3170,6 @@ files = [ name = "typing-extensions" version = "4.6.2" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3335,7 +3181,6 @@ files = [ name = "uc-micro-py" version = "1.0.2" description = "Micro subset of unicode data files for linkify-it-py projects." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3350,7 +3195,6 @@ test = ["coverage", "pytest", "pytest-cov"] name = "unidecode" version = "1.2.0" description = "ASCII transliterations of Unicode text" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3362,7 +3206,6 @@ files = [ name = "uritemplate" version = "3.0.1" description = "URI templates" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3374,7 +3217,6 @@ files = [ name = "urllib3" version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -3391,7 +3233,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "virtualenv" version = "20.23.0" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3412,7 +3253,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess name = "wcwidth" version = "0.2.6" description = "Measures the displayed width of unicode strings in a terminal" -category = "main" optional = false python-versions = "*" files = [ @@ -3424,7 +3264,6 @@ files = [ name = "websocket-client" version = "0.59.0" description = "WebSocket client for Python with low level API options" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3439,7 +3278,6 @@ six = "*" name = "wheel" version = "0.40.0" description = "A built-package format for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3454,7 +3292,6 @@ test = ["pytest (>=6.0.0)"] name = "wrapt" version = "1.15.0" description = "Module for decorators, wrappers and monkey patching." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" files = [ @@ -3539,7 +3376,6 @@ files = [ name = "wsrpc-aiohttp" version = "3.2.0" description = "WSRPC is the RPC over WebSocket for aiohttp" -category = "main" optional = false python-versions = ">3.5.*, <4" files = [ @@ -3560,7 +3396,6 @@ ujson = ["ujson"] name = "yarl" version = "1.9.2" description = "Yet another URL library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -3648,7 +3483,6 @@ multidict = ">=4.0" name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -3665,5 +3499,5 @@ docs = [] [metadata] lock-version = "2.0" -python-versions = ">=3.9.1,<3.10" -content-hash = "d2b8da22dcd11e0b03f19b9b79e51f205156c5ce75e41cc0225392e9afd8803b" +python-versions = "~3.9" +content-hash = "bc3e256094db6e33894840bb6a5adda4473d3736b852433ad8d5bd478c7e0c1c" From 817ef16840467b55c0832d74696d5ba29ad20929 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 18 Sep 2023 18:04:08 +0200 Subject: [PATCH 421/429] lower version of click to 7.1.2 to support older python versions (#5629) --- poetry.lock | 11 ++++------- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2b9fab4bc4..d074a0c3d9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -560,18 +560,15 @@ files = [ [[package]] name = "click" -version = "8.1.3" +version = "7.1.2" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "clique" version = "1.6.1" diff --git a/pyproject.toml b/pyproject.toml index e2d35f3c6d..68d31cf4ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ appdirs = { git = "https://github.com/ActiveState/appdirs.git", branch = "master blessed = "^1.17" # openpype terminal formatting coolname = "*" clique = "1.6.*" -Click = "^8" +Click = "7.1.2" dnspython = "^2.1.0" ftrack-python-api = "^2.3.3" arrow = "^0.17" From 87d8ff66bbb90dd81a93266ba16cf56273d93613 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 19 Sep 2023 15:47:43 +0000 Subject: [PATCH 422/429] [Automated] Release --- CHANGELOG.md | 122 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 935e7dbc4c..9001e9fec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,128 @@ # Changelog +## [3.16.8](https://github.com/ynput/OpenPype/tree/3.16.8) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.16.8) + +### **🚀 Enhancements** + + +
+Chore: Remove schema from OpenPype root #5355 + +Remove unused schema directory in root of repository which was moved inside openpype/pipeline/schema. + + +___ + +
+ + +
+Igniter: Allow custom Qt scale factor rounding policy #5554 + +Do not force `PassThrough` rounding policy if different policy is defined via env variable. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Chore: Lower urllib3 to support older OpenSSL #5538 + +Lowered `urllib3` to `1.26.16` to support older OpenSSL. + + +___ + +
+ + +
+Chore: Do not try to add schema to zip files #5557 + +Do not add `schema` folder to zip file. This fixes issue cause by https://github.com/ynput/OpenPype/pull/5355 . + + +___ + +
+ + +
+Chore: Lower click dependency version #5629 + +Lower click version to support older versions of python. + + +___ + +
+ +### **Merged pull requests** + + +
+Bump certifi from 2023.5.7 to 2023.7.22 #5351 + +Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.5.7 to 2023.7.22. +
+Commits + +
+
+ + +[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=certifi&package-manager=pip&previous-version=2023.5.7&new-version=2023.7.22)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +You can trigger a rebase of this PR by commenting `@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually +- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) +You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts). + +
+ +> **Note** +> Automatic rebases have been disabled on this pull request as it has been open for over 30 days. + +___ + +
+ + + + ## [3.16.7](https://github.com/ynput/OpenPype/tree/3.16.7) diff --git a/openpype/version.py b/openpype/version.py index c593f0f71f..0578bb8780 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.7" +__version__ = "3.16.8" diff --git a/pyproject.toml b/pyproject.toml index b394a895e3..c5d5e87f49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.7" # OpenPype +version = "3.16.8" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 5bf4b826ac960103f121f094506100e993507bef Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Tue, 19 Sep 2023 17:56:06 +0200 Subject: [PATCH 423/429] update release --- CHANGELOG.md | 4 ++-- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9001e9fec5..4bcf66a210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## [3.16.8](https://github.com/ynput/OpenPype/tree/3.16.8) +## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0) -[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.16.8) +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.17.0) ### **🚀 Enhancements** diff --git a/openpype/version.py b/openpype/version.py index 0578bb8780..281e756967 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.8" +__version__ = "3.17.0" diff --git a/pyproject.toml b/pyproject.toml index 233cf73db8..d0b1ecf589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.8" # OpenPype +version = "3.17.0" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 77600e624c31697f515ac37d3d0834253d0951bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 Sep 2023 15:48:46 +0000 Subject: [PATCH 424/429] 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 9159d3fe4a..be515983ca 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.8 - 3.16.7 - 3.16.7-nightly.2 - 3.16.7-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.3 - 3.14.10-nightly.2 - 3.14.10-nightly.1 - - 3.14.9 validations: required: true - type: dropdown From dc5c730cba6119342600dcbe6a5a13d6d1c4b569 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 Sep 2023 16:01:55 +0000 Subject: [PATCH 425/429] 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 be515983ca..0762eb2f20 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,7 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: - - 3.16.8 + - 3.17.0 - 3.16.7 - 3.16.7-nightly.2 - 3.16.7-nightly.1 From 7195479080639489b65a2715b3fba74414dc8815 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 20 Sep 2023 03:24:46 +0000 Subject: [PATCH 426/429] [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 281e756967..483b70436a 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.0" +__version__ = "3.17.1-nightly.1" From 41845651b2aaf701b3a7d27c0091a23948c3c2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Wed, 20 Sep 2023 14:38:35 +0200 Subject: [PATCH 427/429] Update setup.cfg Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 216bae848f..ead9b25164 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ omit = /tests directory = ./coverage [tool:pytest] -norecursedirs = repos/* openpype/modules/ftrack/* +norecursedirs = openpype/modules/ftrack/* [isort] line_length = 79 From d91f54f2f74edac0a729513639f40ed2558e5ff5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 20 Sep 2023 19:18:33 +0300 Subject: [PATCH 428/429] rename files and make validator optional --- ...e_unreal_staticmesh.py => create_staticmesh.py} | 14 +++++++------- .../plugins/publish/collect_staticmesh_type.py | 2 +- .../publish/validate_unreal_staticmesh_naming.py | 2 +- .../settings/defaults/project_settings/global.json | 3 +-- .../defaults/project_settings/houdini.json | 4 ++-- .../schemas/schema_houdini_create.json | 4 ++-- server_addon/core/server/settings/tools.py | 3 +-- .../houdini/server/settings/publish_plugins.py | 12 ++++++------ 8 files changed, 21 insertions(+), 23 deletions(-) rename openpype/hosts/houdini/plugins/create/{create_unreal_staticmesh.py => create_staticmesh.py} (91%) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py similarity index 91% rename from openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py rename to openpype/hosts/houdini/plugins/create/create_staticmesh.py index 2f92def54a..ea0b36f03f 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py @@ -6,11 +6,11 @@ from openpype.lib import BoolDef, EnumDef import hou -class CreateUnrealStaticMesh(plugin.HoudiniCreator): - """Unreal Static Meshes with collisions. """ +class CreateStaticMesh(plugin.HoudiniCreator): + """Static Meshes as FBX. """ - identifier = "io.openpype.creators.houdini.unrealstaticmesh.fbx" - label = "Unreal - Static Mesh (FBX)" + identifier = "io.openpype.creators.houdini.staticmesh.fbx" + label = "Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" @@ -20,7 +20,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): instance_data.update({"node_type": "filmboxfbx"}) - instance = super(CreateUnrealStaticMesh, self).create( + instance = super(CreateStaticMesh, self).create( subset_name, instance_data, pre_create_data) @@ -60,7 +60,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): """Add settings for users. """ - attrs = super(CreateUnrealStaticMesh, self).get_pre_create_attr_defs() + attrs = super(CreateStaticMesh, self).get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " "Export node when it's a " @@ -93,7 +93,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): The default subset name templates for Unreal include {asset} and thus we should pass that along as dynamic data. """ - dynamic_data = super(CreateUnrealStaticMesh, self).get_dynamic_data( + dynamic_data = super(CreateStaticMesh, self).get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["asset"] = asset_doc["name"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py index 263d7c1001..db9efec7a1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -15,6 +15,6 @@ class CollectStaticMeshType(pyblish.api.InstancePlugin): def process(self, instance): - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.staticmesh.fbx": # noqa: E501 # Marking this instance as FBX triggers the FBX extractor. instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 791db8198f..ae3c7e5602 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -42,7 +42,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, def apply_settings(cls, project_settings, system_settings): settings = ( - project_settings["houdini"]["create"]["CreateUnrealStaticMesh"] + project_settings["houdini"]["create"]["CreateStaticMesh"] ) cls.collision_prefixes = settings["collision_prefixes"] cls.static_mesh_prefix = settings["static_mesh_prefix"] diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 52ac745f6d..06a595d1c5 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -429,8 +429,7 @@ "staticMesh" ], "hosts": [ - "maya", - "houdini" + "maya" ], "task_types": [], "tasks": [], diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 6964db0013..5392fc34dd 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -19,7 +19,7 @@ ], "ext": ".ass" }, - "CreateUnrealStaticMesh": { + "CreateStaticMesh": { "enabled": true, "default_variants": [ "Main" @@ -127,7 +127,7 @@ "active": true }, "ValidateUnrealStaticMeshName": { - "enabled": true, + "enabled": false, "optional": true, "active": true } 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 b19761df91..cd8c260124 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 @@ -42,8 +42,8 @@ { "type": "dict", "collapsible": true, - "key": "CreateUnrealStaticMesh", - "label": "Create Unreal - Static Mesh", + "key": "CreateStaticMesh", + "label": "Create Static Mesh", "checkbox_key": "enabled", "children": [ { diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 5dbe6ab215..7befc795e4 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -370,8 +370,7 @@ DEFAULT_TOOLS_VALUES = { "staticMesh" ], "hosts": [ - "maya", - "houdini" + "maya" ], "task_types": [], "tasks": [], diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 6ceff028a5..58240b0205 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class CreateArnoldAssModel(BaseSettingsModel): ext: str = Field(Title="Extension") -class CreateUnrealStaticMeshModel(BaseSettingsModel): +class CreateStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") default_variants: list[str] = Field( default_factory=list, @@ -39,9 +39,9 @@ class CreatePluginsModel(BaseSettingsModel): default_factory=CreateArnoldAssModel, title="Create Alembic Camera") # "-" is not compatible in the new model - CreateUnrealStaticMesh: CreateUnrealStaticMeshModel = Field( - default_factory=CreateUnrealStaticMeshModel, - title="Create Unreal_Static Mesh" + CreateStaticMesh: CreateStaticMeshModel = Field( + default_factory=CreateStaticMeshModel, + title="Create Static Mesh" ) CreateAlembicCamera: CreatorModel = Field( default_factory=CreatorModel, @@ -81,7 +81,7 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"], "ext": ".ass" }, - "CreateUnrealStaticMesh": { + "CreateStaticMesh": { "enabled": True, "default_variants": [ "Main" @@ -212,7 +212,7 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "active": True }, "ValidateUnrealStaticMeshName": { - "enabled": True, + "enabled": False, "optional": True, "active": True } From 23e9f5e504c482bc13268d16190378142b5ca936 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:26:33 +0200 Subject: [PATCH 429/429] TVPaint: Fix review family extraction (#5637) * mark review family representation for review * implemented 'get_publish_instance_families' in publish lib * use 'get_publish_instance_families' in tvpaint extract sequence --- .../plugins/publish/extract_sequence.py | 8 +++++-- openpype/pipeline/publish/__init__.py | 2 ++ openpype/pipeline/publish/lib.py | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index a13a91de46..fd568b2826 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -6,7 +6,10 @@ from PIL import Image import pyblish.api -from openpype.pipeline.publish import KnownPublishError +from openpype.pipeline.publish import ( + KnownPublishError, + get_publish_instance_families, +) from openpype.hosts.tvpaint.api.lib import ( execute_george, execute_george_through_file, @@ -140,8 +143,9 @@ class ExtractSequence(pyblish.api.Extractor): ) # Fill tags and new families from project settings + instance_families = get_publish_instance_families(instance) tags = [] - if "review" in instance.data["families"]: + if "review" in instance_families: tags.append("review") # Sequence of one frame diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index 0c57915c05..3a82d6f565 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -40,6 +40,7 @@ from .lib import ( apply_plugin_settings_automatically, get_plugin_settings, get_publish_instance_label, + get_publish_instance_families, ) from .abstract_expected_files import ExpectedFiles @@ -87,6 +88,7 @@ __all__ = ( "apply_plugin_settings_automatically", "get_plugin_settings", "get_publish_instance_label", + "get_publish_instance_families", "ExpectedFiles", diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 1ae6ea43b2..4d9443f635 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -1002,3 +1002,27 @@ def get_publish_instance_label(instance): or instance.data.get("name") or str(instance) ) + + +def get_publish_instance_families(instance): + """Get all families of the instance. + + Look for families under 'family' and 'families' keys in instance data. + Value of 'family' is used as first family and then all other families + in random order. + + Args: + pyblish.api.Instance: Instance to get families from. + + Returns: + list[str]: List of families. + """ + + family = instance.data.get("family") + families = set(instance.data.get("families") or []) + output = [] + if family: + output.append(family) + families.discard(family) + output.extend(families) + return output