diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 2d9915609a8..83a4b6a8d4d 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,22 @@ 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
+ - 3.16.4
+ - 3.16.4-nightly.3
+ - 3.16.4-nightly.2
+ - 3.16.4-nightly.1
+ - 3.16.3
+ - 3.16.3-nightly.5
+ - 3.16.3-nightly.4
+ - 3.16.3-nightly.3
+ - 3.16.3-nightly.2
+ - 3.16.3-nightly.1
+ - 3.16.2
+ - 3.16.2-nightly.2
- 3.16.2-nightly.1
- 3.16.1
- 3.16.0
@@ -119,22 +135,6 @@ body:
- 3.14.9-nightly.1
- 3.14.8
- 3.14.8-nightly.4
- - 3.14.8-nightly.3
- - 3.14.8-nightly.2
- - 3.14.8-nightly.1
- - 3.14.7
- - 3.14.7-nightly.8
- - 3.14.7-nightly.7
- - 3.14.7-nightly.6
- - 3.14.7-nightly.5
- - 3.14.7-nightly.4
- - 3.14.7-nightly.3
- - 3.14.7-nightly.2
- - 3.14.7-nightly.1
- - 3.14.6
- - 3.14.6-nightly.3
- - 3.14.6-nightly.2
- - 3.14.6-nightly.1
validations:
required: true
- type: dropdown
diff --git a/.gitignore b/.gitignore
index e5019a4e74c..622d55fb883 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,7 +37,7 @@ Temporary Items
###########
/build
/dist/
-/server_addon/package/*
+/server_addon/packages/*
/vendor/bin/*
/vendor/python/*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07b95c7343a..f1948b1a3f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,1328 @@
# Changelog
+## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.3...3.16.4)
+
+### **🆕 New features**
+
+
+
+Feature: Download last published workfile specify version #4998
+
+Setting `workfile_version` key to hook's `self.launch_context.data` allow you to specify the workfile version you want sync service to download if none is matched locally. This is helpful if the last version hasn't been correctly published/synchronized, and you want to recover the previous one (or some you'd like).Version could be set in two ways:
+- OP's absolute version, matching the `version` index in DB.
+- Relative version in reverse order from the last one: `-2`, `-3`...I don't know where I should write documentation about that.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Maya: allow not creation of group for Import loaders #5427
+
+This PR enhances previous one. All ReferenceLoaders could not wrap imported products into explicit group.Also `Import` Loaders have same options. Control for this is separate in Settings, eg. Reference might wrap loaded items in group, `Import` might not.
+
+
+___
+
+
+
+
+
+3dsMax: Settings for Ayon #5388
+
+Max Addon Setting for Ayon
+
+
+___
+
+
+
+
+
+General: Navigation to Folder from Launcher #5404
+
+Adds an action in launcher to open the directory of the asset.
+
+
+___
+
+
+
+
+
+Chore: Default variant in create plugin #5429
+
+Attribute `default_variant` on create plugins always returns string and if default variant is not filled other ways how to get one are implemented.
+
+
+___
+
+
+
+
+
+Publisher: Thumbnail widget enhancements #5439
+
+Thumbnails widget in Publisher has new 3 options to choose from: Paste (from clipboard), Take screenshot and Browse. Clear button and new options are not visible by default, user must expand options button to show them.
+
+
+___
+
+
+
+
+
+AYON: Update ayon api to '0.3.5' #5460
+
+Updated ayon-python-api to 0.3.5.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+AYON: Apply unknown ayon settings first #5435
+
+Settings of custom addons are available in converted settings.
+
+
+___
+
+
+
+
+
+Maya: Fix wrong subset name of render family in deadline #5442
+
+New Publisher is creating different subset names than previously which resulted in duplication of `render` string in final subset name of `render` family published on Deadline.This PR solves that, it also fixes issues with legacy instances from old publisher, it matches the subset name as was before.This solves same issue in Max implementation.
+
+
+___
+
+
+
+
+
+Maya: Fix setting of version to workfile instance #5452
+
+If there are multiple instances of renderlayer published, previous logic resulted in unpredictable rewrite of instance family to 'workfile' if `Sync render version with workfile` was on.
+
+
+___
+
+
+
+
+
+Maya: Context plugin shouldn't be tied to family #5464
+
+`Maya Current File` collector was tied to `workfile` unnecessary. It should run even if `workile` instance is not being published.
+
+
+___
+
+
+
+
+
+Unreal: Fix loading hero version for static and skeletal meshes #5393
+
+Fixed a problem with loading hero versions for static ans skeletal meshes.
+
+
+___
+
+
+
+
+
+TVPaint: Fix 'repeat' behavior #5412
+
+Calculation of frames for repeat behavior is working correctly.
+
+
+___
+
+
+
+
+
+AYON: Thumbnails cache and api prep #5437
+
+Moved thumbnails cache from ayon python api to OpenPype and prepare AYON thumbnail resolver for new api functions. Current implementation should work with old and new ayon-python-api.
+
+
+___
+
+
+
+
+
+Nuke: Name of the Read Node should be updated correctly when switching versions or assets. #5444
+
+Bug fixing of the read node's name not being updated correctly when setting version or switching asset.
+
+
+___
+
+
+
+
+
+Farm publishing: asymmetric handles fixed #5446
+
+Handles are now set correctly on farm published product version if asymmetric were set to shot attributes.
+
+
+___
+
+
+
+
+
+Scene Inventory: Provider icons fix #5450
+
+Fix how provider icons are accessed in scene inventory.
+
+
+___
+
+
+
+
+
+Fix typo on Deadline OP plugin name #5453
+
+Surprised that no one has hit this bug yet... but it seems like there was a typo on the name of the OP Deadline plugin when submitting jobs to it.
+
+
+___
+
+
+
+
+
+AYON: Fix version attributes update #5472
+
+Fixed updates of attribs in AYON mode.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Added missing defaults for import_loader #5447
+
+
+___
+
+
+
+
+
+Bug: Local settings don't open on 3.14.7 #5220
+
+### Before posting a new ticket, have you looked through the documentation to find an answer?
+
+Yes I have
+
+### Have you looked through the existing tickets to find any related issues ?
+
+Not yet
+
+### Author of the bug
+
+@FadyFS
+
+### Version
+
+3.15.11-nightly.3
+
+### What platform you are running OpenPype on?
+
+Linux / Centos
+
+### Current Behavior:
+
+the previous behavior (bug) :
+
+
+
+### Expected Behavior:
+
+
+
+
+### What type of bug is it ?
+
+Happened only once in a particular configuration
+
+### Which project / workfile / asset / ...
+
+open settings with 3.14.7
+
+### Steps To Reproduce:
+
+1. Run openpype on the 3.15.11-nightly.3 version
+2. Open settings in 3.14.7 version
+
+### Relevant log output:
+
+_No response_
+
+### Additional context:
+
+_No response_
+
+___
+
+
+
+
+
+Tests: Add automated targets for tests #5443
+
+Without it plugins with 'automated' targets won't be triggered (eg `CloseAE` etc.)
+
+
+___
+
+
+
+
+
+
+## [3.16.3](https://github.com/ynput/OpenPype/tree/3.16.3)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.2...3.16.3)
+
+### **🆕 New features**
+
+
+
+AYON: 3rd party addon usage #5300
+
+Prepare OpenPype code to be able use `ayon-third-party` addon which supply ffmpeg and OpenImageIO executables. Because they both can support to define custom arguments (more than one) a new functions were needed to supply.New functions are `get_ffmpeg_tool_args` and `get_oiio_tool_args`. They work similar to previous but instead of string are returning list of strings. All places using previous functions `get_ffmpeg_tool_path` and `get_oiio_tool_path` are now using new ones. They should be backwards compatible and even with addon if returns single argument.
+
+
+___
+
+
+
+
+
+AYON: Addon settings in OpenPype #5347
+
+Moved settings addons to OpenPype server addon. Modified create package to create zip files for server for each settings addon and for openpype addon.
+
+
+___
+
+
+
+
+
+AYON: Add folder to template data #5417
+
+Added `folder` to template data, so `{folder[name]}` can be used in templates.
+
+
+___
+
+
+
+
+
+Option to start versioning from 0 #5262
+
+This PR adds a settings option to start all versioning from 0.This PR will replace #4455.
+
+
+___
+
+
+
+
+
+Ayon: deadline implementation #5321
+
+Quick implementation of deadline in Ayon. New Ayon plugin added for Deadline repository
+
+
+___
+
+
+
+
+
+AYON: Remove AYON launch logic from OpenPype #5348
+
+Removed AYON launch logic from OpenPype. The logic is outdated at this moment and is replaced by `ayon-launcher`.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Bug: Error on multiple instance rig with maya #5310
+
+I change endswith method by startswith method because the set are automacaly name out_SET, out_SET1, out_SET2 ...
+
+
+___
+
+
+
+
+
+Applications: Use prelaunch hooks to extract environments #5387
+
+Environment variable preparation is based on prelaunch hooks. This should allow to pass OCIO environment variables to farm jobs.
+
+
+___
+
+
+
+
+
+Applications: Launch hooks cleanup #5395
+
+Use `set` instead of `list` for filtering attributes in launch hooks. Celaction hooks dir does not contain `__init__.py`. Celaction prelaunch hook is reusing `CELACTION_ROOT_DIR`. Launch hooks are using full import from `openpype.lib.applications`.
+
+
+___
+
+
+
+
+
+Applications: Environment variables order #5245
+
+Changed order of set environment variables. First are set context environment variables and then project environment overrides. Also asset and task environemnt variables are optional.
+
+
+___
+
+
+
+
+
+Autosave preferences can be read after Nuke opens the script #5295
+
+Looks like I need to open the script in Nuke to be able to correctly load the autosave preferences.This PR reads the Nuke script in context, and offers owerwriting the current script with autosaved one if autosave exists.
+
+
+___
+
+
+
+
+
+Resolve: Update with compatible resolve version and latest docs #5317
+
+Missing information about compatible Resolve version and latest docs from https://github.com/ynput/OpenPype/tree/develop/openpype/hosts/resolve
+
+
+___
+
+
+
+
+
+Chore: Remove deprecated functions #5323
+
+Removed functions/classes that are deprecated and marked to be removed.
+
+
+___
+
+
+
+
+
+Nuke Render and Prerender nodes Process Order - OP-3555 #5332
+
+This PR exposes control over the order of processing of the instances, by sorting the instances created. The sorting happens on the `render_order` and subset name. If the knob `render_order` is found on the instance, we'll sort by that first before sorting by subset name.`render_order` instances are processed before nodes without `render_order`. This could be extended in the future by querying other knobs but I dont know of a usecase for this.Hardcoded the creator `order` attribute of the `prerender` class to be before the `render`. Could be exposed to the user/studio but dont know of a use case for this.
+
+
+___
+
+
+
+
+
+Unreal: Python Environment Improvements #5344
+
+Automatically set `UE_PYTHONPATH` as `PYTHONPATH` when launching Unreal.
+
+
+___
+
+
+
+
+
+Unreal: Custom location for Unreal Ayon Plugin #5346
+
+Added a new environment variable `AYON_BUILT_UNREAL_PLUGIN` to set an already existing and built Ayon Plugin for Unreal.
+
+
+___
+
+
+
+
+
+Unreal: Better handling of Exceptions in UE Worker threads #5349
+
+Implemented a new `UEWorker` base class to handle exception during the execution of UE Workers.
+
+
+___
+
+
+
+
+
+Houdini: Add farm toggle on creation menu #5350
+
+Deadline Farm publishing and Rendering for Houdini was possible with this PR #4825 farm publishing is enabled by default some ROP nodes which may surprise new users (like me).I think adding a toggle (on by default) on creation UI is better so that users will be aware that there's a farm option for this publish instance.ROPs Modified :
+- [x] Mantra ROP
+- [x] Karma ROP
+- [x] Arnold ROP
+- [x] Redshift ROP
+- [x] Vray ROP
+
+
+___
+
+
+
+
+
+Ftrack: Sync to avalon settings #5353
+
+Added roles settings for sync to avalon action.
+
+
+___
+
+
+
+
+
+Chore: Schemas inside OpenPype #5354
+
+Moved/copied schemas from repository root inside openpype/pipeline.
+
+
+___
+
+
+
+
+
+AYON: Addons creation enhancements #5356
+
+Enhanced AYON addons creation. Fix issue with `Pattern` typehint. Zip filenames contain version. OpenPype package is skipping modules that are already separated in AYON. Updated settings of addons.
+
+
+___
+
+
+
+
+
+AYON: Update staging icons #5372
+
+Updated staging icons for staging mode.
+
+
+___
+
+
+
+
+
+Enhancement: Houdini Update pointcache labels #5373
+
+To me it's logical to find pointcaches types listed one after another, but they were named differentlySo, I made this PR to update their labels
+
+
+___
+
+
+
+
+
+nuke: split write node product instance features #5389
+
+Improving Write node product instances by allowing precise activation of specific features.
+
+
+___
+
+
+
+
+
+Max: Use the empty modifiers in container to store AYON Parameter #5396
+
+Instead of adding AYON/OP Parameter along with other attributes inside the container, empty modifiers would be created to store AYON/OP custom attributes
+
+
+___
+
+
+
+
+
+AfterEffects: Removed unused imports #5397
+
+Removed unused import from extract local render plugin file.
+
+
+___
+
+
+
+
+
+Nuke: adding BBox knob type to settings #5405
+
+Nuke knob types in settings having new `Box` type for reposition nodes like Crop or Reformat.
+
+
+___
+
+
+
+
+
+SyncServer: Existence of module is optional #5413
+
+Existence of SyncServer module is optional and not required. Added `sync_server` module back to ignored modules when openpype addon is created for AYON. Command `syncserver` is marked as deprecated and redirected to sync server cli.
+
+
+___
+
+
+
+
+
+Webpublisher: Self contain test publish logic #5414
+
+Moved test logic of publishing to webpublisher. Simplified `remote_publish` to remove webpublisher specific logic.
+
+
+___
+
+
+
+
+
+Webpublisher: Cleanup targets #5418
+
+Removed `remote` target from webpublisher and replaced it with 2 targets `webpublisher` and `automated`.
+
+
+___
+
+
+
+
+
+nuke: update server addon settings with box #5419
+
+updtaing nuke ayon server settings for Box option in knob types.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: fix validate frame range on review attached to other instances #5296
+
+Fixes situation where frame range validator can't be turned off on models if they are attached to reviewable camera in Maya.
+
+
+___
+
+
+
+
+
+Maya: Apply project settings to creators #5303
+
+Project settings were not applied to the creators.
+
+
+___
+
+
+
+
+
+Maya: Validate Model Content #5336
+
+`assemblies` in `cmds.ls` does not seem to work;
+```python
+
+from maya import cmds
+
+
+content_instance = ['|group2|pSphere1_GEO', '|group2|pSphere1_GEO|pSphere1_GEOShape', '|group1|pSphere1_GEO', '|group1|pSphere1_GEO|pSphere1_GEOShape']
+assemblies = cmds.ls(content_instance, assemblies=True, long=True)
+print(assemblies)
+```
+
+Fixing with string splitting instead.
+
+
+___
+
+
+
+
+
+Bugfix: Maya update defaults variable #5368
+
+So, something was forgotten while moving out from `LegacyCreator` to `NewCreator``LegacyCreator` used `defaults` to list suggested subset names which was changed into `default_variants` in the the `NewCreator`and setting `defaults` to any values has no effect!This update affects:
+- [x] Model
+- [x] Set Dress
+
+
+___
+
+
+
+
+
+Chore: Python 2 support fix #5375
+
+Fix Python 2 support by adding `click` into python 2 dependencies and removing f-string from maya.
+
+
+___
+
+
+
+
+
+Maya: do not create top level group on reference #5402
+
+This PR allows to not wrapping loaded referenced assets in top level group either explicitly for artist or by configuration in Settings.Artists can control group creation in ReferenceLoader options.Default no group creation could be set by emptying `Group Name` in `project_settings/maya/load/reference_loader`
+
+
+___
+
+
+
+
+
+Settings: Houdini & Maya create plugin settings #5436
+
+Fixes related to Maya and Houdini settings. Renamed `defaults` to `default_variants` in plugin settings to match attribute name on create plugin in both OpenPype and AYON settings. Fixed Houdini AYON settings where were missing settings for defautlt varaints and fixed Maya AYON settings where default factory had wrong assignment.
+
+
+___
+
+
+
+
+
+Maya: Hide CreateAnimation #5297
+
+When converting `animation` family or loading a `rig` family, need to include the `animation` creator but hide it in creator context.
+
+
+___
+
+
+
+
+
+Nuke Anamorphic slate - Read pixel aspect from input #5304
+
+When asset pixel aspect differs from rendered pixel aspect, Nuke slate pixel aspect is not longer taken from asset, but is readed via ffprobe.
+
+
+___
+
+
+
+
+
+Nuke - Allow ExtractReviewDataMov with no timecode knob #5305
+
+ExtractReviewDataMov allows to specify file type. Trying to write some other extension than mov fails on generate_mov assuming that mov64_write_timecode knob exists.
+
+
+___
+
+
+
+
+
+Nuke: removing settings schema with defaults for OpenPype #5306
+
+continuation of https://github.com/ynput/OpenPype/pull/5275
+
+
+___
+
+
+
+
+
+Bugfix: Dependency without 'inputLinks' not downloaded #5337
+
+Remove condition that avoids downloading dependency without `inputLinks`.
+
+
+___
+
+
+
+
+
+Bugfix: Houdini Creator use selection even if it was toggled off #5359
+
+When creating many product types (families) one after another without refreshing the creator window manually if you toggled `Use selection` once, all the later product types will use selection even if it was toggled offHere's Before it will keep use selection even if it was toggled off, unless you refresh window manuallyhttps://github.com/ynput/OpenPype/assets/20871534/8b890122-5b53-4c6b-897d-6a2f3aa3388aHere's After it works as expectedhttps://github.com/ynput/OpenPype/assets/20871534/6b1db990-de1b-428e-8828-04ab59a44e28
+
+
+___
+
+
+
+
+
+Houdini: Correct camera selection for karma renderer when using selected node #5360
+
+When user creates the karma rop with selected camera by use selection, it will give the error message of "no render camera found in selection".This PR is to fix the bug of creating karma rop when using selected camera node in Houdini
+
+
+___
+
+
+
+
+
+AYON: Environment variables and functions #5361
+
+Prepare code for ayon-launcher compatibility. Fix ayon launcher subprocess calls, added more checks for `AYON_SERVER_ENABLED`, use ayon launcher suitable environment variables in AYON mode and changed outputs of some functions. Replaced usages of `OPENPYPE_REPOS_ROOT` environment variable with `PACKAGE_DIR` variable -> correct paths are used.
+
+
+___
+
+
+
+
+
+Nuke: farm rendering of prerender ignore roots in nuke #5366
+
+`prerender` family was using wrong subset, same as `render` which should be different.
+
+
+___
+
+
+
+
+
+Bugfix: Houdini update defaults variable #5367
+
+So, something was forgotten while moving out from `LegacyCreator` to `NewCreator``LegacyCreator` used `defaults` to list suggested subset names which was changed into `default_variants` in the the `NewCreator`and setting `defaults` to any values has no effect!This update affects:
+- [x] Arnold ASS
+- [x] Arnold ROP
+- [x] Karma ROP
+- [x] Mantra ROP
+- [x] Redshift ROP
+- [x] VRay ROP
+
+
+___
+
+
+
+
+
+Publisher: Fix create/publish animation #5369
+
+Use geometry movement instead of changing min/max width.
+
+
+___
+
+
+
+
+
+Unreal: Move unreal splash screen to unreal #5370
+
+Moved splash screen code to unreal integration and removed import from Igniter.
+
+
+___
+
+
+
+
+
+Nuke: returned not cleaning of renders folder on the farm #5374
+
+Previous PR enabled explicit cleanup of `renders` folder after farm publishing. This is not matching customer's workflows. Customer wants to have access to files in `renders` folder and potentially redo some frames for long frame sequences.This PR extends logic of marking rendered files for deletion only if instance doesn't have `stagingDir_persistent`.For backwards compatibility all Nuke instances have `stagingDir_persistent` set to True, eg. `renders` folder won't be cleaned after farm publish.
+
+
+___
+
+
+
+
+
+Nuke: loading sequences is working #5376
+
+Loading image sequences was broken after the latest release, version 3.16. However, I am pleased to inform you that it is now functioning as expected.
+
+
+___
+
+
+
+
+
+AYON: Fix settings conversion for ayon addons #5377
+
+AYON addon settings are available in system settings and does not have available the same values in `"modules"` subkey.
+
+
+___
+
+
+
+
+
+Nuke: OCIO env var workflow #5379
+
+The OCIO environment variable needs to be consistently handled across all platforms. Nuke resolves the custom OCIO config path differently depending on the platform, so we included the ocio config path in the workfile with a partial replacement using an environment variable. Additionally, for Windows sessions, we replaced backward slashes with a TCL expression.
+
+
+___
+
+
+
+
+
+Unreal: Fix Unreal build script #5381
+
+Define 'AYON_UNREAL_ROOT' environment variable in unreal addon.
+
+
+___
+
+
+
+
+
+3dsMax: Use relative path to MAX_HOST_DIR #5382
+
+Use `MAX_HOST_DIR` to calculate startup script path instead of use relative path to `OPENPYPE_ROOT` environment variable.
+
+
+___
+
+
+
+
+
+Bugfix: Houdini abc validator error message #5386
+
+When ABC path validator fails, it prints node objects not node paths or namesThis bug happened because of updating `get_invalid` method to return nodes instead of node pathsBeforeAfter
+
+
+___
+
+
+
+
+
+Nuke: node name influence product (subset) name #5392
+
+Nuke now allows users to duplicate publishing instances, making the workflow easier. By duplicating a node and changing its name, users can set the product (subset) name in the publishing context.Users now have the ability to change the variant name in Publisher, which will automatically rename the associated instance node.
+
+
+___
+
+
+
+
+
+Houdini: delete redundant bgeo sop validator #5394
+
+I found out that this `Validate BGEO SOP Path` validator is redundant, it catches two cases that are already implemented in "Validate Output Node". "Validate Output Node" works with `bgeo` as well as `abc` because `"pointcache"` is listed in its families
+
+
+___
+
+
+
+
+
+Nuke: workfile is not reopening after change of context #5399
+
+Nuke no longer reopens the latest workfile when the context is changed to a different task using the Workfile tool. The issue also affected the Script Clean (from Nuke File menu) and Close feature, but it has now been fixed.
+
+
+___
+
+
+
+
+
+Bugfix: houdini hard coded project settings #5400
+
+I made this PR to solve the issue with hard-coded settings in houdini
+
+
+___
+
+
+
+
+
+AYON: 3dsMax settings #5401
+
+Keep `adsk_3dsmax` group in applications settings.
+
+
+___
+
+
+
+
+
+Bugfix: update defaults to default_variants in maya and houdini OP DCC settings #5407
+
+On moving out to new creator in Maya and Houdini updating settings was missed.
+
+
+___
+
+
+
+
+
+Applications: Attributes creation #5408
+
+Applications addon does not cause infinite server restart loop.
+
+
+___
+
+
+
+
+
+Max: fix the bug of handling Object deletion in OP Parameter #5410
+
+If the object is added to the OP parameter and user delete it in the scene thereafter, it will error out the container with OP attributes. This PR resolves the bug.This PR also fixes the bug of not adding the attribute into OP parameter correctly when the user enables "use selections" to link the object into the OP parameter.
+
+
+___
+
+
+
+
+
+Colorspace: including environments from launcher process #5411
+
+Fixed bug in GitHub PR where the OCIO config template was not properly formatting environment variables from System Settings `general/environment`.
+
+
+___
+
+
+
+
+
+Nuke: workfile template fixes #5428
+
+Some bunch of small bugs needed to be fixed
+
+
+___
+
+
+
+
+
+Houdini, Max: Fix missed function interface change #5430
+
+This PR https://github.com/ynput/OpenPype/pull/5321/files from @kalisp missed updating the `add_render_job_env_var` in Houdini and Max as they are passing an extra arg:
+```
+TypeError: add_render_job_env_var() takes 1 positional argument but 2 were given
+```
+
+
+___
+
+
+
+
+
+Scene Inventory: Fix issue with 'sync_server' #5431
+
+Fix accesss to `sync_server` attribute in scene inventory.
+
+
+___
+
+
+
+
+
+Unpack project: Fix import issue #5433
+
+Added `load_json_file`, `replace_project_documents` and `store_project_documents` to mongo init.
+
+
+___
+
+
+
+
+
+Chore: Versions post fixes #5441
+
+Fixed issues caused by my fault. Filled right version value to anatomy data.
+
+
+___
+
+
+
+### **📃 Testing**
+
+
+
+Tests: Copy file_handler as it will be removed by purging ayon code #5357
+
+Ayon code will get purged in the future from this repo/addon, therefore all `ayon_common` will be gone. `file_handler` gets internalized to tests as it is not used anywhere else.
+
+
+___
+
+
+
+
+
+
+## [3.16.2](https://github.com/ynput/OpenPype/tree/3.16.2)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.1...3.16.2)
+
+### **🆕 New features**
+
+
+
+Fusion - Set selected tool to active #5327
+
+When you run the action to select a node, this PR makes the node-flow show the selected node + you'll see the nodes controls in the inspector.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Maya: All base create plugins #5326
+
+Prepared base classes for each creator type in Maya. Extended `MayaCreatorBase` to have default implementations of common logic with instances which is used in each type of plugin.
+
+
+___
+
+
+
+
+
+Windows: Support long paths on zip updates. #5265
+
+Support long paths for version extract on Windows.Use case is when having long paths in for example an addon. You can install to the C drive but because the zip files are extracted in the local users folder, it'll add additional sub directories to the paths and quickly get too long paths for Windows to handle the zip updates.
+
+
+___
+
+
+
+
+
+Blender: Added setting to set resolution and start/end frames at startup #5338
+
+This PR adds `set_resolution_startup`and `set_frames_startup` settings. They automatically set respectively the resolution and start/end frames and FPS in Blender when opening a file or creating a new one.
+
+
+___
+
+
+
+
+
+Blender: Support for ExtractBurnin #5339
+
+This PR adds support for ExtractBurnin for Blender, when publishing a Review.
+
+
+___
+
+
+
+
+
+Blender: Extract Camera as Alembic #5343
+
+Added support to extract Alembic Cameras in Blender.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Validate Instance In Context #5335
+
+Missing new publisher error so the repair action shows up.
+
+
+___
+
+
+
+
+
+Settings: Fix default settings #5311
+
+Fixed defautl settings for shotgrid. Renamed `FarmRootEnumEntity` to `DynamicEnumEntity` and removed doubled ABC metaclass definition (all settings entities have abstract metaclass).
+
+
+___
+
+
+
+
+
+Deadline: missing context argument #5312
+
+Updated function arguments
+
+
+___
+
+
+
+
+
+Qt UI: Multiselection combobox PySide6 compatibility #5314
+
+- The check states are replaced with the values for PySide6
+- `QtCore.Qt.ItemIsUserTristate` is used instead of `QtCore.Qt.ItemIsTristate` to avoid crashes on PySide6
+
+
+___
+
+
+
+
+
+Docker: handle openssl 1.1.1 for centos 7 docker build #5319
+
+Move to python 3.9 has added need to use openssl 1.1.x - but it is not by default available on centos 7 image. This is fixing it.
+
+
+___
+
+
+
+
+
+houdini: fix typo in redshift proxy #5320
+
+I believe there's a typo in `create_redshift_proxy.py` ( extra ` ) in filename, and I made this PR to suggest a fix
+
+
+___
+
+
+
+
+
+Houdini: fix wrong creator identifier in pointCache workflow #5324
+
+FIxing a bug in publishing alembics, were invalid creator identifier caused missing family association.
+
+
+___
+
+
+
+
+
+Fix colorspace compatibility check #5334
+
+for some reason a user may have `PyOpenColorIO` installed to his machine, _in my case it came with renderman._it can trick the compatibility check as `import PyOpenColorIO` won't raise an error however it may be an old version _like my case_Beforecompatibility check was true and It used wrapper directly After Fix It will use wrapper via subprocess instead
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Remove forgotten dev logging #5315
+
+
+___
+
+
+
+
+
+
## [3.16.1](https://github.com/ynput/OpenPype/tree/3.16.1)
@@ -177,7 +1499,7 @@ ___
Add functional base for API Documentation using Sphinx and AutoAPI.
-After unsuccessful #2512, #834 and #210 this is yet another try. But this time without ambition to solve the whole issue. This is making Shinx script to work and nothing else. Any changes and improvements in API docs should be made in subsequent PRs.
+After unsuccessful #2512, #834 and #210 this is yet another try. But this time without ambition to solve the whole issue. This is making Shinx script to work and nothing else. Any changes and improvements in API docs should be made in subsequent PRs.
## How to use it
@@ -188,7 +1510,7 @@ cd .\docs
make.bat html
```
-or
+or
```sh
cd ./docs
@@ -203,7 +1525,7 @@ During the build you'll see tons of red errors that are pointing to our issues:
Invalid import are usually wrong relative imports (too deep) or circular imports.
2) **Invalid doc-strings**
- Doc-strings to be processed into documentation needs to follow some syntax - this can be checked by running
+ Doc-strings to be processed into documentation needs to follow some syntax - this can be checked by running
`pydocstyle` that is already included with OpenPype
3) **Invalid markdown/rst files**
md/rst files can be included inside rst files using `.. include::` directive. But they have to be properly formatted.
@@ -1390,11 +2712,11 @@ ___
Houdini: Redshift ROP image format bug #5218
-Problem :
-"RS_outputFileFormat" parm value was missing
-and there were more "image_format" than redshift rop supports
+Problem :
+"RS_outputFileFormat" parm value was missing
+and there were more "image_format" than redshift rop supports
-Fix:
+Fix:
1) removed unnecessary formats from `image_format_enum`
2) add the selected format value to `RS_outputFileFormat`
___
@@ -3571,7 +4893,7 @@ ___
Maya Load References - Add Display Handle Setting #4904
-When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings.
+When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings.
___
@@ -3679,7 +5001,7 @@ ___
Patchelf version locked #4853
-For Centos dockerfile it is necessary to lock the patchelf version to the older, otherwise the build process fails.
+For Centos dockerfile it is necessary to lock the patchelf version to the older, otherwise the build process fails.
___
diff --git a/README.md b/README.md
index 6caed8061c5..ce98f845e68 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:
@@ -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/ayon_start.py b/ayon_start.py
deleted file mode 100644
index 458c46bba6c..00000000000
--- a/ayon_start.py
+++ /dev/null
@@ -1,483 +0,0 @@
-# -*- coding: utf-8 -*-
-"""Main entry point for AYON command.
-
-Bootstrapping process of AYON.
-"""
-import os
-import sys
-import site
-import traceback
-import contextlib
-
-
-# Enabled logging debug mode when "--debug" is passed
-if "--verbose" in sys.argv:
- expected_values = (
- "Expected: notset, debug, info, warning, error, critical"
- " or integer [0-50]."
- )
- idx = sys.argv.index("--verbose")
- sys.argv.pop(idx)
- if idx < len(sys.argv):
- value = sys.argv.pop(idx)
- else:
- raise RuntimeError((
- f"Expect value after \"--verbose\" argument. {expected_values}"
- ))
-
- log_level = None
- low_value = value.lower()
- if low_value.isdigit():
- log_level = int(low_value)
- elif low_value == "notset":
- log_level = 0
- elif low_value == "debug":
- log_level = 10
- elif low_value == "info":
- log_level = 20
- elif low_value == "warning":
- log_level = 30
- elif low_value == "error":
- log_level = 40
- elif low_value == "critical":
- log_level = 50
-
- if log_level is None:
- raise ValueError((
- "Unexpected value after \"--verbose\" "
- f"argument \"{value}\". {expected_values}"
- ))
-
- os.environ["OPENPYPE_LOG_LEVEL"] = str(log_level)
- os.environ["AYON_LOG_LEVEL"] = str(log_level)
-
-# Enable debug mode, may affect log level if log level is not defined
-if "--debug" in sys.argv:
- sys.argv.remove("--debug")
- os.environ["AYON_DEBUG"] = "1"
- os.environ["OPENPYPE_DEBUG"] = "1"
-
-if "--automatic-tests" in sys.argv:
- sys.argv.remove("--automatic-tests")
- os.environ["IS_TEST"] = "1"
-
-SKIP_HEADERS = False
-if "--skip-headers" in sys.argv:
- sys.argv.remove("--skip-headers")
- SKIP_HEADERS = True
-
-SKIP_BOOTSTRAP = False
-if "--skip-bootstrap" in sys.argv:
- sys.argv.remove("--skip-bootstrap")
- SKIP_BOOTSTRAP = True
-
-if "--use-staging" in sys.argv:
- sys.argv.remove("--use-staging")
- os.environ["AYON_USE_STAGING"] = "1"
- os.environ["OPENPYPE_USE_STAGING"] = "1"
-
-if "--headless" in sys.argv:
- os.environ["AYON_HEADLESS_MODE"] = "1"
- os.environ["OPENPYPE_HEADLESS_MODE"] = "1"
- sys.argv.remove("--headless")
-
-elif (
- os.getenv("AYON_HEADLESS_MODE") != "1"
- or os.getenv("OPENPYPE_HEADLESS_MODE") != "1"
-):
- os.environ.pop("AYON_HEADLESS_MODE", None)
- os.environ.pop("OPENPYPE_HEADLESS_MODE", None)
-
-elif (
- os.getenv("AYON_HEADLESS_MODE")
- != os.getenv("OPENPYPE_HEADLESS_MODE")
-):
- os.environ["OPENPYPE_HEADLESS_MODE"] = (
- os.environ["AYON_HEADLESS_MODE"]
- )
-
-IS_BUILT_APPLICATION = getattr(sys, "frozen", False)
-HEADLESS_MODE_ENABLED = os.getenv("AYON_HEADLESS_MODE") == "1"
-
-_pythonpath = os.getenv("PYTHONPATH", "")
-_python_paths = _pythonpath.split(os.pathsep)
-if not IS_BUILT_APPLICATION:
- # Code root defined by `start.py` directory
- AYON_ROOT = os.path.dirname(os.path.abspath(__file__))
- _dependencies_path = site.getsitepackages()[-1]
-else:
- AYON_ROOT = os.path.dirname(sys.executable)
-
- # add dependencies folder to sys.pat for frozen code
- _dependencies_path = os.path.normpath(
- os.path.join(AYON_ROOT, "dependencies")
- )
-# add stuff from `/dependencies` to PYTHONPATH.
-sys.path.append(_dependencies_path)
-_python_paths.append(_dependencies_path)
-
-# Vendored python modules that must not be in PYTHONPATH environment but
-# are required for OpenPype processes
-sys.path.insert(0, os.path.join(AYON_ROOT, "vendor", "python"))
-
-# Add common package to sys path
-# - common contains common code for bootstraping and OpenPype processes
-sys.path.insert(0, os.path.join(AYON_ROOT, "common"))
-
-# This is content of 'core' addon which is ATM part of build
-common_python_vendor = os.path.join(
- AYON_ROOT,
- "openpype",
- "vendor",
- "python",
- "common"
-)
-# Add tools dir to sys path for pyblish UI discovery
-tools_dir = os.path.join(AYON_ROOT, "openpype", "tools")
-for path in (AYON_ROOT, common_python_vendor, tools_dir):
- while path in _python_paths:
- _python_paths.remove(path)
-
- while path in sys.path:
- sys.path.remove(path)
-
- _python_paths.insert(0, path)
- sys.path.insert(0, path)
-
-os.environ["PYTHONPATH"] = os.pathsep.join(_python_paths)
-
-# enabled AYON state
-os.environ["USE_AYON_SERVER"] = "1"
-# Set this to point either to `python` from venv in case of live code
-# or to `ayon` or `ayon_console` in case of frozen code
-os.environ["AYON_EXECUTABLE"] = sys.executable
-os.environ["OPENPYPE_EXECUTABLE"] = sys.executable
-os.environ["AYON_ROOT"] = AYON_ROOT
-os.environ["OPENPYPE_ROOT"] = AYON_ROOT
-os.environ["OPENPYPE_REPOS_ROOT"] = AYON_ROOT
-os.environ["AYON_MENU_LABEL"] = "AYON"
-os.environ["AVALON_LABEL"] = "AYON"
-# Set name of pyblish UI import
-os.environ["PYBLISH_GUI"] = "pyblish_pype"
-# Set builtin OCIO root
-os.environ["BUILTIN_OCIO_ROOT"] = os.path.join(
- AYON_ROOT,
- "vendor",
- "bin",
- "ocioconfig",
- "OpenColorIOConfigs"
-)
-
-import blessed # noqa: E402
-import certifi # noqa: E402
-
-
-if sys.__stdout__:
- term = blessed.Terminal()
-
- def _print(message: str):
- if message.startswith("!!! "):
- print(f'{term.orangered2("!!! ")}{message[4:]}')
- elif message.startswith(">>> "):
- print(f'{term.aquamarine3(">>> ")}{message[4:]}')
- elif message.startswith("--- "):
- print(f'{term.darkolivegreen3("--- ")}{message[4:]}')
- elif message.startswith("*** "):
- print(f'{term.gold("*** ")}{message[4:]}')
- elif message.startswith(" - "):
- print(f'{term.wheat(" - ")}{message[4:]}')
- elif message.startswith(" . "):
- print(f'{term.tan(" . ")}{message[4:]}')
- elif message.startswith(" - "):
- print(f'{term.seagreen3(" - ")}{message[7:]}')
- elif message.startswith(" ! "):
- print(f'{term.goldenrod(" ! ")}{message[7:]}')
- elif message.startswith(" * "):
- print(f'{term.aquamarine1(" * ")}{message[7:]}')
- elif message.startswith(" "):
- print(f'{term.darkseagreen3(" ")}{message[4:]}')
- else:
- print(message)
-else:
- def _print(message: str):
- print(message)
-
-
-# if SSL_CERT_FILE is not set prior to OpenPype launch, we set it to point
-# to certifi bundle to make sure we have reasonably new CA certificates.
-if not os.getenv("SSL_CERT_FILE"):
- os.environ["SSL_CERT_FILE"] = certifi.where()
-elif os.getenv("SSL_CERT_FILE") != certifi.where():
- _print("--- your system is set to use custom CA certificate bundle.")
-
-from ayon_api import get_base_url
-from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY
-from ayon_common import is_staging_enabled
-from ayon_common.connection.credentials import (
- ask_to_login_ui,
- add_server,
- need_server_or_login,
- load_environments,
- set_environments,
- create_global_connection,
- confirm_server_login,
-)
-from ayon_common.distribution import (
- AyonDistribution,
- BundleNotFoundError,
- show_missing_bundle_information,
-)
-
-
-def set_global_environments() -> None:
- """Set global OpenPype's environments."""
- import acre
-
- from openpype.settings import get_general_environments
-
- general_env = get_general_environments()
-
- # first resolve general environment because merge doesn't expect
- # values to be list.
- # TODO: switch to OpenPype environment functions
- merged_env = acre.merge(
- acre.compute(acre.parse(general_env), cleanup=False),
- dict(os.environ)
- )
- env = acre.compute(
- merged_env,
- cleanup=False
- )
- os.environ.clear()
- os.environ.update(env)
-
- # Hardcoded default values
- os.environ["PYBLISH_GUI"] = "pyblish_pype"
- # Change scale factor only if is not set
- if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ:
- os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
-
-
-def set_addons_environments():
- """Set global environments for OpenPype modules.
-
- This requires to have OpenPype in `sys.path`.
- """
-
- import acre
- from openpype.modules import ModulesManager
-
- modules_manager = ModulesManager()
-
- # Merge environments with current environments and update values
- if module_envs := modules_manager.collect_global_environments():
- parsed_envs = acre.parse(module_envs)
- env = acre.merge(parsed_envs, dict(os.environ))
- os.environ.clear()
- os.environ.update(env)
-
-
-def _connect_to_ayon_server():
- load_environments()
- if not need_server_or_login():
- create_global_connection()
- return
-
- if HEADLESS_MODE_ENABLED:
- _print("!!! Cannot open v4 Login dialog in headless mode.")
- _print((
- "!!! Please use `{}` to specify server address"
- " and '{}' to specify user's token."
- ).format(SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY))
- sys.exit(1)
-
- current_url = os.environ.get(SERVER_URL_ENV_KEY)
- url, token, username = ask_to_login_ui(current_url, always_on_top=True)
- if url is not None and token is not None:
- confirm_server_login(url, token, username)
- return
-
- if url is not None:
- add_server(url, username)
-
- _print("!!! Login was not successful.")
- sys.exit(0)
-
-
-def _check_and_update_from_ayon_server():
- """Gets addon info from v4, compares with local folder and updates it.
-
- Raises:
- RuntimeError
- """
-
- distribution = AyonDistribution()
- bundle = None
- bundle_name = None
- try:
- bundle = distribution.bundle_to_use
- if bundle is not None:
- bundle_name = bundle.name
- except BundleNotFoundError as exc:
- bundle_name = exc.bundle_name
-
- if bundle is None:
- url = get_base_url()
- if not HEADLESS_MODE_ENABLED:
- show_missing_bundle_information(url, bundle_name)
-
- elif bundle_name:
- _print((
- f"!!! Requested release bundle '{bundle_name}'"
- " is not available on server."
- ))
- _print(
- "!!! Check if selected release bundle"
- f" is available on the server '{url}'."
- )
-
- else:
- mode = "staging" if is_staging_enabled() else "production"
- _print(
- f"!!! No release bundle is set as {mode} on the AYON server."
- )
- _print(
- "!!! Make sure there is a release bundle set"
- f" as \"{mode}\" on the AYON server '{url}'."
- )
- sys.exit(1)
-
- distribution.distribute()
- distribution.validate_distribution()
- os.environ["AYON_BUNDLE_NAME"] = bundle_name
-
- python_paths = [
- path
- for path in os.getenv("PYTHONPATH", "").split(os.pathsep)
- if path
- ]
-
- for path in distribution.get_sys_paths():
- sys.path.insert(0, path)
- if path not in python_paths:
- python_paths.append(path)
- os.environ["PYTHONPATH"] = os.pathsep.join(python_paths)
-
-
-def boot():
- """Bootstrap OpenPype."""
-
- from openpype.version import __version__
-
- # TODO load version
- os.environ["OPENPYPE_VERSION"] = __version__
- os.environ["AYON_VERSION"] = __version__
-
- _connect_to_ayon_server()
- _check_and_update_from_ayon_server()
-
- # delete OpenPype module and it's submodules from cache so it is used from
- # specific version
- modules_to_del = [
- sys.modules.pop(module_name)
- for module_name in tuple(sys.modules)
- if module_name == "openpype" or module_name.startswith("openpype.")
- ]
-
- for module_name in modules_to_del:
- with contextlib.suppress(AttributeError, KeyError):
- del sys.modules[module_name]
-
-
-def main_cli():
- from openpype import cli
- from openpype.version import __version__
- from openpype.lib import terminal as t
-
- _print(">>> loading environments ...")
- _print(" - global AYON ...")
- set_global_environments()
- _print(" - for addons ...")
- set_addons_environments()
-
- # print info when not running scripts defined in 'silent commands'
- if not SKIP_HEADERS:
- info = get_info(is_staging_enabled())
- info.insert(0, f">>> Using AYON from [ {AYON_ROOT} ]")
-
- t_width = 20
- with contextlib.suppress(ValueError, OSError):
- t_width = os.get_terminal_size().columns - 2
-
- _header = f"*** AYON [{__version__}] "
- info.insert(0, _header + "-" * (t_width - len(_header)))
-
- for i in info:
- t.echo(i)
-
- try:
- cli.main(obj={}, prog_name="ayon")
- except Exception: # noqa
- exc_info = sys.exc_info()
- _print("!!! AYON crashed:")
- traceback.print_exception(*exc_info)
- sys.exit(1)
-
-
-def script_cli():
- """Run and execute script."""
-
- filepath = os.path.abspath(sys.argv[1])
-
- # Find '__main__.py' in directory
- if os.path.isdir(filepath):
- new_filepath = os.path.join(filepath, "__main__.py")
- if not os.path.exists(new_filepath):
- raise RuntimeError(
- f"can't find '__main__' module in '{filepath}'")
- filepath = new_filepath
-
- # Add parent dir to sys path
- sys.path.insert(0, os.path.dirname(filepath))
-
- # Read content and execute
- with open(filepath, "r") as stream:
- content = stream.read()
-
- exec(compile(content, filepath, "exec"), globals())
-
-
-def get_info(use_staging=None) -> list:
- """Print additional information to console."""
-
- inf = []
- if use_staging:
- inf.append(("AYON variant", "staging"))
- else:
- inf.append(("AYON variant", "production"))
- inf.append(("AYON bundle", os.getenv("AYON_BUNDLE")))
-
- # NOTE add addons information
-
- maximum = max(len(i[0]) for i in inf)
- formatted = []
- for info in inf:
- padding = (maximum - len(info[0])) + 1
- formatted.append(f'... {info[0]}:{" " * padding}[ {info[1]} ]')
- return formatted
-
-
-def main():
- if not SKIP_BOOTSTRAP:
- boot()
-
- args = list(sys.argv)
- args.pop(0)
- if args and os.path.exists(args[0]):
- script_cli()
- else:
- main_cli()
-
-
-if __name__ == "__main__":
- main()
diff --git a/common/ayon_common/__init__.py b/common/ayon_common/__init__.py
deleted file mode 100644
index ddabb7da2f4..00000000000
--- a/common/ayon_common/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from .utils import (
- IS_BUILT_APPLICATION,
- is_staging_enabled,
- get_local_site_id,
- get_ayon_appdirs,
- get_ayon_launch_args,
-)
-
-
-__all__ = (
- "IS_BUILT_APPLICATION",
- "is_staging_enabled",
- "get_local_site_id",
- "get_ayon_appdirs",
- "get_ayon_launch_args",
-)
diff --git a/common/ayon_common/connection/credentials.py b/common/ayon_common/connection/credentials.py
deleted file mode 100644
index 7f70cb7992a..00000000000
--- a/common/ayon_common/connection/credentials.py
+++ /dev/null
@@ -1,511 +0,0 @@
-"""Handle credentials and connection to server for client application.
-
-Cache and store used server urls. Store/load API keys to/from keyring if
-needed. Store metadata about used urls, usernames for the urls and when was
-the connection with the username established.
-
-On bootstrap is created global connection with information about site and
-client version. The connection object lives in 'ayon_api'.
-"""
-
-import os
-import json
-import platform
-import datetime
-import contextlib
-import subprocess
-import tempfile
-from typing import Optional, Union, Any
-
-import ayon_api
-
-from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY
-from ayon_api.exceptions import UrlError
-from ayon_api.utils import (
- validate_url,
- is_token_valid,
- logout_from_server,
-)
-
-from ayon_common.utils import (
- get_ayon_appdirs,
- get_local_site_id,
- get_ayon_launch_args,
- is_staging_enabled,
-)
-
-
-class ChangeUserResult:
- def __init__(
- self, logged_out, old_url, old_token, old_username,
- new_url, new_token, new_username
- ):
- shutdown = logged_out
- restart = new_url is not None and new_url != old_url
- token_changed = new_token is not None and new_token != old_token
-
- self.logged_out = logged_out
- self.old_url = old_url
- self.old_token = old_token
- self.old_username = old_username
- self.new_url = new_url
- self.new_token = new_token
- self.new_username = new_username
-
- self.shutdown = shutdown
- self.restart = restart
- self.token_changed = token_changed
-
-
-def _get_servers_path():
- return get_ayon_appdirs("used_servers.json")
-
-
-def get_servers_info_data():
- """Metadata about used server on this machine.
-
- Store data about all used server urls, last used url and user username for
- the url. Using this metadata we can remember which username was used per
- url if token stored in keyring loose lifetime.
-
- Returns:
- dict[str, Any]: Information about servers.
- """
-
- data = {}
- servers_info_path = _get_servers_path()
- if not os.path.exists(servers_info_path):
- dirpath = os.path.dirname(servers_info_path)
- if not os.path.exists(dirpath):
- os.makedirs(dirpath)
-
- return data
-
- with open(servers_info_path, "r") as stream:
- with contextlib.suppress(BaseException):
- data = json.load(stream)
- return data
-
-
-def add_server(url: str, username: str):
- """Add server to server info metadata.
-
- This function will also mark the url as last used url on the machine so on
- next launch will be used.
-
- Args:
- url (str): Server url.
- username (str): Name of user used to log in.
- """
-
- servers_info_path = _get_servers_path()
- data = get_servers_info_data()
- data["last_server"] = url
- if "urls" not in data:
- data["urls"] = {}
- data["urls"][url] = {
- "updated_dt": datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"),
- "username": username,
- }
-
- with open(servers_info_path, "w") as stream:
- json.dump(data, stream)
-
-
-def remove_server(url: str):
- """Remove server url from servers information.
-
- This should be used on logout to completelly loose information about server
- on the machine.
-
- Args:
- url (str): Server url.
- """
-
- if not url:
- return
-
- servers_info_path = _get_servers_path()
- data = get_servers_info_data()
- if data.get("last_server") == url:
- data["last_server"] = None
-
- if "urls" in data:
- data["urls"].pop(url, None)
-
- with open(servers_info_path, "w") as stream:
- json.dump(data, stream)
-
-
-def get_last_server(
- data: Optional[dict[str, Any]] = None
-) -> Union[str, None]:
- """Last server used to log in on this machine.
-
- Args:
- data (Optional[dict[str, Any]]): Prepared server information data.
-
- Returns:
- Union[str, None]: Last used server url.
- """
-
- if data is None:
- data = get_servers_info_data()
- return data.get("last_server")
-
-
-def get_last_username_by_url(
- url: str,
- data: Optional[dict[str, Any]] = None
-) -> Union[str, None]:
- """Get last username which was used for passed url.
-
- Args:
- url (str): Server url.
- data (Optional[dict[str, Any]]): Servers info.
-
- Returns:
- Union[str, None]: Username.
- """
-
- if not url:
- return None
-
- if data is None:
- data = get_servers_info_data()
-
- if urls := data.get("urls"):
- if url_info := urls.get(url):
- return url_info.get("username")
- return None
-
-
-def get_last_server_with_username():
- """Receive last server and username used in last connection.
-
- Returns:
- tuple[Union[str, None], Union[str, None]]: Url and username.
- """
-
- data = get_servers_info_data()
- url = get_last_server(data)
- username = get_last_username_by_url(url)
- return url, username
-
-
-class TokenKeyring:
- # Fake username with hardcoded username
- username_key = "username"
-
- def __init__(self, url):
- try:
- import keyring
-
- except Exception as exc:
- raise NotImplementedError(
- "Python module `keyring` is not available."
- ) from exc
-
- # hack for cx_freeze and Windows keyring backend
- if platform.system().lower() == "windows":
- from keyring.backends import Windows
-
- keyring.set_keyring(Windows.WinVaultKeyring())
-
- self._url = url
- self._keyring_key = f"AYON/{url}"
-
- def get_value(self):
- import keyring
-
- return keyring.get_password(self._keyring_key, self.username_key)
-
- def set_value(self, value):
- import keyring
-
- if value is not None:
- keyring.set_password(self._keyring_key, self.username_key, value)
- return
-
- with contextlib.suppress(keyring.errors.PasswordDeleteError):
- keyring.delete_password(self._keyring_key, self.username_key)
-
-
-def load_token(url: str) -> Union[str, None]:
- """Get token for url from keyring.
-
- Args:
- url (str): Server url.
-
- Returns:
- Union[str, None]: Token for passed url available in keyring.
- """
-
- return TokenKeyring(url).get_value()
-
-
-def store_token(url: str, token: str):
- """Store token by url to keyring.
-
- Args:
- url (str): Server url.
- token (str): User token to server.
- """
-
- TokenKeyring(url).set_value(token)
-
-
-def ask_to_login_ui(
- url: Optional[str] = None,
- always_on_top: Optional[bool] = False
-) -> tuple[str, str, str]:
- """Ask user to login using UI.
-
- This should be used only when user is not yet logged in at all or available
- credentials are invalid. To change credentials use 'change_user_ui'
- function.
-
- Use a subprocess to show UI.
-
- Args:
- url (Optional[str]): Server url that could be prefilled in UI.
- always_on_top (Optional[bool]): Window will be drawn on top of
- other windows.
-
- Returns:
- tuple[str, str, str]: Url, user's token and username.
- """
-
- current_dir = os.path.dirname(os.path.abspath(__file__))
- ui_dir = os.path.join(current_dir, "ui")
-
- if url is None:
- url = get_last_server()
- username = get_last_username_by_url(url)
- data = {
- "url": url,
- "username": username,
- "always_on_top": always_on_top,
- }
-
- with tempfile.NamedTemporaryFile(
- mode="w", prefix="ayon_login", suffix=".json", delete=False
- ) as tmp:
- output = tmp.name
- json.dump(data, tmp)
-
- code = subprocess.call(
- get_ayon_launch_args(ui_dir, "--skip-bootstrap", output))
- if code != 0:
- raise RuntimeError("Failed to show login UI")
-
- with open(output, "r") as stream:
- data = json.load(stream)
- os.remove(output)
- return data["output"]
-
-
-def change_user_ui() -> ChangeUserResult:
- """Change user using UI.
-
- Show UI to user where he can change credentials or url. Output will contain
- all information about old/new values of url, username, api key. If user
- confirmed or declined values.
-
- Returns:
- ChangeUserResult: Information about user change.
- """
-
- from .ui import change_user
-
- url, username = get_last_server_with_username()
- token = load_token(url)
- result = change_user(url, username, token)
- new_url, new_token, new_username, logged_out = result
-
- output = ChangeUserResult(
- logged_out, url, token, username,
- new_url, new_token, new_username
- )
- if output.logged_out:
- logout(url, token)
-
- elif output.token_changed:
- change_token(
- output.new_url,
- output.new_token,
- output.new_username,
- output.old_url
- )
- return output
-
-
-def change_token(
- url: str,
- token: str,
- username: Optional[str] = None,
- old_url: Optional[str] = None
-):
- """Change url and token in currently running session.
-
- Function can also change server url, in that case are previous credentials
- NOT removed from cache.
-
- Args:
- url (str): Url to server.
- token (str): New token to be used for url connection.
- username (Optional[str]): Username of logged user.
- old_url (Optional[str]): Previous url. Value from 'get_last_server'
- is used if not entered.
- """
-
- if old_url is None:
- old_url = get_last_server()
- if old_url and old_url == url:
- remove_url_cache(old_url)
-
- # TODO check if ayon_api is already connected
- add_server(url, username)
- store_token(url, token)
- ayon_api.change_token(url, token)
-
-
-def remove_url_cache(url: str):
- """Clear cache for server url.
-
- Args:
- url (str): Server url which is removed from cache.
- """
-
- store_token(url, None)
-
-
-def remove_token_cache(url: str, token: str):
- """Remove token from local cache of url.
-
- Is skipped if cached token under the passed url is not the same
- as passed token.
-
- Args:
- url (str): Url to server.
- token (str): Token to be removed from url cache.
- """
-
- if load_token(url) == token:
- remove_url_cache(url)
-
-
-def logout(url: str, token: str):
- """Logout from server and throw token away.
-
- Args:
- url (str): Url from which should be logged out.
- token (str): Token which should be used to log out.
- """
-
- remove_server(url)
- ayon_api.close_connection()
- ayon_api.set_environments(None, None)
- remove_token_cache(url, token)
- logout_from_server(url, token)
-
-
-def load_environments():
- """Load environments on startup.
-
- Handle environments needed for connection with server. Environments are
- 'AYON_SERVER_URL' and 'AYON_API_KEY'.
-
- Server is looked up from environment. Already set environent is not
- changed. If environemnt is not filled then last server stored in appdirs
- is used.
-
- Token is skipped if url is not available. Otherwise, is also checked from
- env and if is not available then uses 'load_token' to try to get token
- based on server url.
- """
-
- server_url = os.environ.get(SERVER_URL_ENV_KEY)
- if not server_url:
- server_url = get_last_server()
- if not server_url:
- return
- os.environ[SERVER_URL_ENV_KEY] = server_url
-
- if not os.environ.get(SERVER_API_ENV_KEY):
- if token := load_token(server_url):
- os.environ[SERVER_API_ENV_KEY] = token
-
-
-def set_environments(url: str, token: str):
- """Change url and token environemnts in currently running process.
-
- Args:
- url (str): New server url.
- token (str): User's token.
- """
-
- ayon_api.set_environments(url, token)
-
-
-def create_global_connection():
- """Create global connection with site id and client version.
-
- Make sure the global connection in 'ayon_api' have entered site id and
- client version.
-
- Set default settings variant to use based on 'is_staging_enabled'.
- """
-
- ayon_api.create_connection(
- get_local_site_id(), os.environ.get("AYON_VERSION")
- )
- ayon_api.set_default_settings_variant(
- "staging" if is_staging_enabled() else "production"
- )
-
-
-def need_server_or_login() -> bool:
- """Check if server url or login to the server are needed.
-
- It is recommended to call 'load_environments' on startup before this check.
- But in some cases this function could be called after startup.
-
- Returns:
- bool: 'True' if server and token are available. Otherwise 'False'.
- """
-
- server_url = os.environ.get(SERVER_URL_ENV_KEY)
- if not server_url:
- return True
-
- try:
- server_url = validate_url(server_url)
- except UrlError:
- return True
-
- token = os.environ.get(SERVER_API_ENV_KEY)
- if token:
- return not is_token_valid(server_url, token)
-
- token = load_token(server_url)
- if token:
- return not is_token_valid(server_url, token)
- return True
-
-
-def confirm_server_login(url, token, username):
- """Confirm login of user and do necessary stepts to apply changes.
-
- This should not be used on "change" of user but on first login.
-
- Args:
- url (str): Server url where user authenticated.
- token (str): API token used for authentication to server.
- username (Union[str, None]): Username related to API token.
- """
-
- add_server(url, username)
- store_token(url, token)
- set_environments(url, token)
- create_global_connection()
diff --git a/common/ayon_common/connection/ui/__init__.py b/common/ayon_common/connection/ui/__init__.py
deleted file mode 100644
index 96e573df0d0..00000000000
--- a/common/ayon_common/connection/ui/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from .login_window import (
- ServerLoginWindow,
- ask_to_login,
- change_user,
-)
-
-
-__all__ = (
- "ServerLoginWindow",
- "ask_to_login",
- "change_user",
-)
diff --git a/common/ayon_common/connection/ui/__main__.py b/common/ayon_common/connection/ui/__main__.py
deleted file mode 100644
index 719b2b8ef58..00000000000
--- a/common/ayon_common/connection/ui/__main__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import sys
-import json
-
-from ayon_common.connection.ui.login_window import ask_to_login
-
-
-def main(output_path):
- with open(output_path, "r") as stream:
- data = json.load(stream)
-
- url = data.get("url")
- username = data.get("username")
- always_on_top = data.get("always_on_top", False)
- out_url, out_token, out_username = ask_to_login(
- url, username, always_on_top=always_on_top)
-
- data["output"] = [out_url, out_token, out_username]
- with open(output_path, "w") as stream:
- json.dump(data, stream)
-
-
-if __name__ == "__main__":
- main(sys.argv[-1])
diff --git a/common/ayon_common/connection/ui/login_window.py b/common/ayon_common/connection/ui/login_window.py
deleted file mode 100644
index 94c239852ea..00000000000
--- a/common/ayon_common/connection/ui/login_window.py
+++ /dev/null
@@ -1,710 +0,0 @@
-import traceback
-
-from qtpy import QtWidgets, QtCore, QtGui
-
-from ayon_api.exceptions import UrlError
-from ayon_api.utils import validate_url, login_to_server
-
-from ayon_common.resources import (
- get_resource_path,
- get_icon_path,
- load_stylesheet,
-)
-from ayon_common.ui_utils import set_style_property, get_qt_app
-
-from .widgets import (
- PressHoverButton,
- PlaceholderLineEdit,
-)
-
-
-class LogoutConfirmDialog(QtWidgets.QDialog):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.setWindowTitle("Logout confirmation")
-
- message_widget = QtWidgets.QWidget(self)
-
- message_label = QtWidgets.QLabel(
- (
- "You are going to logout. This action will close this"
- " application and will invalidate your login."
- " All other applications launched with this login won't be"
- " able to use it anymore.
"
- "You can cancel logout and only change server and user login"
- " in login dialog.
"
- "Press OK to confirm logout."
- ),
- message_widget
- )
- message_label.setWordWrap(True)
-
- message_layout = QtWidgets.QHBoxLayout(message_widget)
- message_layout.setContentsMargins(0, 0, 0, 0)
- message_layout.addWidget(message_label, 1)
-
- sep_frame = QtWidgets.QFrame(self)
- sep_frame.setObjectName("Separator")
- sep_frame.setMinimumHeight(2)
- sep_frame.setMaximumHeight(2)
-
- footer_widget = QtWidgets.QWidget(self)
-
- cancel_btn = QtWidgets.QPushButton("Cancel", footer_widget)
- confirm_btn = QtWidgets.QPushButton("OK", footer_widget)
-
- footer_layout = QtWidgets.QHBoxLayout(footer_widget)
- footer_layout.setContentsMargins(0, 0, 0, 0)
- footer_layout.addStretch(1)
- footer_layout.addWidget(cancel_btn, 0)
- footer_layout.addWidget(confirm_btn, 0)
-
- main_layout = QtWidgets.QVBoxLayout(self)
- main_layout.addWidget(message_widget, 0)
- main_layout.addStretch(1)
- main_layout.addWidget(sep_frame, 0)
- main_layout.addWidget(footer_widget, 0)
-
- cancel_btn.clicked.connect(self._on_cancel_click)
- confirm_btn.clicked.connect(self._on_confirm_click)
-
- self._cancel_btn = cancel_btn
- self._confirm_btn = confirm_btn
- self._result = False
-
- def showEvent(self, event):
- super().showEvent(event)
- self._match_btns_sizes()
-
- def resizeEvent(self, event):
- super().resizeEvent(event)
- self._match_btns_sizes()
-
- def _match_btns_sizes(self):
- width = max(
- self._cancel_btn.sizeHint().width(),
- self._confirm_btn.sizeHint().width()
- )
- self._cancel_btn.setMinimumWidth(width)
- self._confirm_btn.setMinimumWidth(width)
-
- def _on_cancel_click(self):
- self._result = False
- self.reject()
-
- def _on_confirm_click(self):
- self._result = True
- self.accept()
-
- def get_result(self):
- return self._result
-
-
-class ServerLoginWindow(QtWidgets.QDialog):
- default_width = 410
- default_height = 170
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- icon_path = get_icon_path()
- icon = QtGui.QIcon(icon_path)
- self.setWindowIcon(icon)
- self.setWindowTitle("Login to server")
-
- edit_icon_path = get_resource_path("edit.png")
- edit_icon = QtGui.QIcon(edit_icon_path)
-
- # --- URL page ---
- login_widget = QtWidgets.QWidget(self)
-
- user_cred_widget = QtWidgets.QWidget(login_widget)
-
- url_label = QtWidgets.QLabel("URL:", user_cred_widget)
-
- url_widget = QtWidgets.QWidget(user_cred_widget)
-
- url_input = PlaceholderLineEdit(url_widget)
- url_input.setPlaceholderText("< https://ayon.server.com >")
-
- url_preview = QtWidgets.QLineEdit(url_widget)
- url_preview.setReadOnly(True)
- url_preview.setObjectName("LikeDisabledInput")
-
- url_edit_btn = PressHoverButton(user_cred_widget)
- url_edit_btn.setIcon(edit_icon)
- url_edit_btn.setObjectName("PasswordBtn")
-
- url_layout = QtWidgets.QHBoxLayout(url_widget)
- url_layout.setContentsMargins(0, 0, 0, 0)
- url_layout.addWidget(url_input, 1)
- url_layout.addWidget(url_preview, 1)
-
- # --- URL separator ---
- url_cred_sep = QtWidgets.QFrame(self)
- url_cred_sep.setObjectName("Separator")
- url_cred_sep.setMinimumHeight(2)
- url_cred_sep.setMaximumHeight(2)
-
- # --- Login page ---
- username_label = QtWidgets.QLabel("Username:", user_cred_widget)
-
- username_widget = QtWidgets.QWidget(user_cred_widget)
-
- username_input = PlaceholderLineEdit(username_widget)
- username_input.setPlaceholderText("< Artist >")
-
- username_preview = QtWidgets.QLineEdit(username_widget)
- username_preview.setReadOnly(True)
- username_preview.setObjectName("LikeDisabledInput")
-
- username_edit_btn = PressHoverButton(user_cred_widget)
- username_edit_btn.setIcon(edit_icon)
- username_edit_btn.setObjectName("PasswordBtn")
-
- username_layout = QtWidgets.QHBoxLayout(username_widget)
- username_layout.setContentsMargins(0, 0, 0, 0)
- username_layout.addWidget(username_input, 1)
- username_layout.addWidget(username_preview, 1)
-
- password_label = QtWidgets.QLabel("Password:", user_cred_widget)
- password_input = PlaceholderLineEdit(user_cred_widget)
- password_input.setPlaceholderText("< *********** >")
- password_input.setEchoMode(PlaceholderLineEdit.Password)
-
- api_label = QtWidgets.QLabel("API key:", user_cred_widget)
- api_preview = QtWidgets.QLineEdit(user_cred_widget)
- api_preview.setReadOnly(True)
- api_preview.setObjectName("LikeDisabledInput")
-
- show_password_icon_path = get_resource_path("eye.png")
- show_password_icon = QtGui.QIcon(show_password_icon_path)
- show_password_btn = PressHoverButton(user_cred_widget)
- show_password_btn.setObjectName("PasswordBtn")
- show_password_btn.setIcon(show_password_icon)
- show_password_btn.setFocusPolicy(QtCore.Qt.ClickFocus)
-
- cred_msg_sep = QtWidgets.QFrame(self)
- cred_msg_sep.setObjectName("Separator")
- cred_msg_sep.setMinimumHeight(2)
- cred_msg_sep.setMaximumHeight(2)
-
- # --- Credentials inputs ---
- user_cred_layout = QtWidgets.QGridLayout(user_cred_widget)
- user_cred_layout.setContentsMargins(0, 0, 0, 0)
- row = 0
-
- user_cred_layout.addWidget(url_label, row, 0, 1, 1)
- user_cred_layout.addWidget(url_widget, row, 1, 1, 1)
- user_cred_layout.addWidget(url_edit_btn, row, 2, 1, 1)
- row += 1
-
- user_cred_layout.addWidget(url_cred_sep, row, 0, 1, 3)
- row += 1
-
- user_cred_layout.addWidget(username_label, row, 0, 1, 1)
- user_cred_layout.addWidget(username_widget, row, 1, 1, 1)
- user_cred_layout.addWidget(username_edit_btn, row, 2, 2, 1)
- row += 1
-
- user_cred_layout.addWidget(api_label, row, 0, 1, 1)
- user_cred_layout.addWidget(api_preview, row, 1, 1, 1)
- row += 1
-
- user_cred_layout.addWidget(password_label, row, 0, 1, 1)
- user_cred_layout.addWidget(password_input, row, 1, 1, 1)
- user_cred_layout.addWidget(show_password_btn, row, 2, 1, 1)
- row += 1
-
- user_cred_layout.addWidget(cred_msg_sep, row, 0, 1, 3)
- row += 1
-
- user_cred_layout.setColumnStretch(0, 0)
- user_cred_layout.setColumnStretch(1, 1)
- user_cred_layout.setColumnStretch(2, 0)
-
- login_layout = QtWidgets.QVBoxLayout(login_widget)
- login_layout.setContentsMargins(0, 0, 0, 0)
- login_layout.addWidget(user_cred_widget, 1)
-
- # --- Messages ---
- # Messages for users (e.g. invalid url etc.)
- message_label = QtWidgets.QLabel(self)
- message_label.setWordWrap(True)
- message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
-
- footer_widget = QtWidgets.QWidget(self)
- logout_btn = QtWidgets.QPushButton("Logout", footer_widget)
- user_message = QtWidgets.QLabel(footer_widget)
- login_btn = QtWidgets.QPushButton("Login", footer_widget)
- confirm_btn = QtWidgets.QPushButton("Confirm", footer_widget)
-
- footer_layout = QtWidgets.QHBoxLayout(footer_widget)
- footer_layout.setContentsMargins(0, 0, 0, 0)
- footer_layout.addWidget(logout_btn, 0)
- footer_layout.addWidget(user_message, 1)
- footer_layout.addWidget(login_btn, 0)
- footer_layout.addWidget(confirm_btn, 0)
-
- main_layout = QtWidgets.QVBoxLayout(self)
- main_layout.addWidget(login_widget, 0)
- main_layout.addWidget(message_label, 0)
- main_layout.addStretch(1)
- main_layout.addWidget(footer_widget, 0)
-
- url_input.textChanged.connect(self._on_url_change)
- url_input.returnPressed.connect(self._on_url_enter_press)
- username_input.textChanged.connect(self._on_user_change)
- username_input.returnPressed.connect(self._on_username_enter_press)
- password_input.returnPressed.connect(self._on_password_enter_press)
- show_password_btn.change_state.connect(self._on_show_password)
- url_edit_btn.clicked.connect(self._on_url_edit_click)
- username_edit_btn.clicked.connect(self._on_username_edit_click)
- logout_btn.clicked.connect(self._on_logout_click)
- login_btn.clicked.connect(self._on_login_click)
- confirm_btn.clicked.connect(self._on_login_click)
-
- self._message_label = message_label
-
- self._url_widget = url_widget
- self._url_input = url_input
- self._url_preview = url_preview
- self._url_edit_btn = url_edit_btn
-
- self._login_widget = login_widget
-
- self._user_cred_widget = user_cred_widget
- self._username_input = username_input
- self._username_preview = username_preview
- self._username_edit_btn = username_edit_btn
-
- self._password_label = password_label
- self._password_input = password_input
- self._show_password_btn = show_password_btn
- self._api_label = api_label
- self._api_preview = api_preview
-
- self._logout_btn = logout_btn
- self._user_message = user_message
- self._login_btn = login_btn
- self._confirm_btn = confirm_btn
-
- self._url_is_valid = None
- self._credentials_are_valid = None
- self._result = (None, None, None, False)
- self._first_show = True
-
- self._allow_logout = False
- self._logged_in = False
- self._url_edit_mode = False
- self._username_edit_mode = False
-
- def set_allow_logout(self, allow_logout):
- if allow_logout is self._allow_logout:
- return
- self._allow_logout = allow_logout
-
- self._update_states_by_edit_mode()
-
- def _set_logged_in(self, logged_in):
- if logged_in is self._logged_in:
- return
- self._logged_in = logged_in
-
- self._update_states_by_edit_mode()
-
- def _set_url_edit_mode(self, edit_mode):
- if self._url_edit_mode is not edit_mode:
- self._url_edit_mode = edit_mode
- self._update_states_by_edit_mode()
-
- def _set_username_edit_mode(self, edit_mode):
- if self._username_edit_mode is not edit_mode:
- self._username_edit_mode = edit_mode
- self._update_states_by_edit_mode()
-
- def _get_url_user_edit(self):
- url_edit = True
- if self._logged_in and not self._url_edit_mode:
- url_edit = False
- user_edit = url_edit
- if not user_edit and self._logged_in and self._username_edit_mode:
- user_edit = True
- return url_edit, user_edit
-
- def _update_states_by_edit_mode(self):
- url_edit, user_edit = self._get_url_user_edit()
-
- self._url_preview.setVisible(not url_edit)
- self._url_input.setVisible(url_edit)
- self._url_edit_btn.setVisible(self._allow_logout and not url_edit)
-
- self._username_preview.setVisible(not user_edit)
- self._username_input.setVisible(user_edit)
- self._username_edit_btn.setVisible(
- self._allow_logout and not user_edit
- )
-
- self._api_preview.setVisible(not user_edit)
- self._api_label.setVisible(not user_edit)
-
- self._password_label.setVisible(user_edit)
- self._show_password_btn.setVisible(user_edit)
- self._password_input.setVisible(user_edit)
-
- self._logout_btn.setVisible(self._allow_logout and self._logged_in)
- self._login_btn.setVisible(not self._allow_logout)
- self._confirm_btn.setVisible(self._allow_logout)
- self._update_login_btn_state(url_edit, user_edit)
-
- def _update_login_btn_state(self, url_edit=None, user_edit=None, url=None):
- if url_edit is None:
- url_edit, user_edit = self._get_url_user_edit()
-
- if url is None:
- url = self._url_input.text()
-
- enabled = bool(url) and (url_edit or user_edit)
-
- self._login_btn.setEnabled(enabled)
- self._confirm_btn.setEnabled(enabled)
-
- def showEvent(self, event):
- super().showEvent(event)
- if self._first_show:
- self._first_show = False
- self._on_first_show()
-
- def _on_first_show(self):
- self.setStyleSheet(load_stylesheet())
- self.resize(self.default_width, self.default_height)
- self._center_window()
- if self._allow_logout is None:
- self.set_allow_logout(False)
-
- self._update_states_by_edit_mode()
- if not self._url_input.text():
- widget = self._url_input
- elif not self._username_input.text():
- widget = self._username_input
- else:
- widget = self._password_input
-
- self._set_input_focus(widget)
-
- def result(self):
- """Result url and token or login.
-
- Returns:
- Union[Tuple[str, str], Tuple[None, None]]: Url and token used for
- login if was successful otherwise are both set to None.
- """
- return self._result
-
- def _center_window(self):
- """Move window to center of screen."""
-
- if hasattr(QtWidgets.QApplication, "desktop"):
- desktop = QtWidgets.QApplication.desktop()
- screen_idx = desktop.screenNumber(self)
- screen_geo = desktop.screenGeometry(screen_idx)
- else:
- screen = self.screen()
- screen_geo = screen.geometry()
-
- geo = self.frameGeometry()
- geo.moveCenter(screen_geo.center())
- if geo.y() < screen_geo.y():
- geo.setY(screen_geo.y())
- self.move(geo.topLeft())
-
- def _on_url_change(self, text):
- self._update_login_btn_state(url=text)
- self._set_url_valid(None)
- self._set_credentials_valid(None)
- self._url_preview.setText(text)
-
- def _set_url_valid(self, valid):
- if valid is self._url_is_valid:
- return
-
- self._url_is_valid = valid
- self._set_input_valid_state(self._url_input, valid)
-
- def _set_credentials_valid(self, valid):
- if self._credentials_are_valid is valid:
- return
-
- self._credentials_are_valid = valid
- self._set_input_valid_state(self._username_input, valid)
- self._set_input_valid_state(self._password_input, valid)
-
- def _on_url_enter_press(self):
- self._set_input_focus(self._username_input)
-
- def _on_user_change(self, username):
- self._username_preview.setText(username)
-
- def _on_username_enter_press(self):
- self._set_input_focus(self._password_input)
-
- def _on_password_enter_press(self):
- self._login()
-
- def _on_show_password(self, show_password):
- if show_password:
- placeholder_text = "< MySecret124 >"
- echo_mode = QtWidgets.QLineEdit.Normal
- else:
- placeholder_text = "< *********** >"
- echo_mode = QtWidgets.QLineEdit.Password
-
- self._password_input.setEchoMode(echo_mode)
- self._password_input.setPlaceholderText(placeholder_text)
-
- def _on_username_edit_click(self):
- self._username_edit_mode = True
- self._update_states_by_edit_mode()
-
- def _on_url_edit_click(self):
- self._url_edit_mode = True
- self._update_states_by_edit_mode()
-
- def _on_logout_click(self):
- dialog = LogoutConfirmDialog(self)
- dialog.exec_()
- if dialog.get_result():
- self._result = (None, None, None, True)
- self.accept()
-
- def _on_login_click(self):
- self._login()
-
- def _validate_url(self):
- """Use url from input to connect and change window state on success.
-
- Todos:
- Threaded check.
- """
-
- url = self._url_input.text()
- valid_url = None
- try:
- valid_url = validate_url(url)
-
- except UrlError as exc:
- parts = [f"{exc.title}"]
- parts.extend(f"- {hint}" for hint in exc.hints)
- self._set_message("
".join(parts))
-
- except KeyboardInterrupt:
- # Reraise KeyboardInterrupt error
- raise
-
- except BaseException:
- self._set_unexpected_error()
- return
-
- if valid_url is None:
- return False
-
- self._url_input.setText(valid_url)
- return True
-
- def _login(self):
- if (
- not self._login_btn.isEnabled()
- and not self._confirm_btn.isEnabled()
- ):
- return
-
- if not self._url_is_valid:
- self._set_url_valid(self._validate_url())
-
- if not self._url_is_valid:
- self._set_input_focus(self._url_input)
- self._set_credentials_valid(None)
- return
-
- self._clear_message()
-
- url = self._url_input.text()
- username = self._username_input.text()
- password = self._password_input.text()
- try:
- token = login_to_server(url, username, password)
- except BaseException:
- self._set_unexpected_error()
- return
-
- if token is not None:
- self._result = (url, token, username, False)
- self.accept()
- return
-
- self._set_credentials_valid(False)
- message_lines = ["Invalid credentials"]
- if not username.strip():
- message_lines.append("- Username is not filled")
-
- if not password.strip():
- message_lines.append("- Password is not filled")
-
- if username and password:
- message_lines.append("- Check your credentials")
-
- self._set_message("
".join(message_lines))
- self._set_input_focus(self._username_input)
-
- def _set_input_focus(self, widget):
- widget.setFocus(QtCore.Qt.MouseFocusReason)
-
- def _set_input_valid_state(self, widget, valid):
- state = ""
- if valid is True:
- state = "valid"
- elif valid is False:
- state = "invalid"
- set_style_property(widget, "state", state)
-
- def _set_message(self, message):
- self._message_label.setText(message)
-
- def _clear_message(self):
- self._message_label.setText("")
-
- def _set_unexpected_error(self):
- # TODO add traceback somewhere
- # - maybe a button to show or copy?
- traceback.print_exc()
- lines = [
- "Unexpected error happened",
- "- Can be caused by wrong url (leading elsewhere)"
- ]
- self._set_message("
".join(lines))
-
- def set_url(self, url):
- self._url_preview.setText(url)
- self._url_input.setText(url)
- self._validate_url()
-
- def set_username(self, username):
- self._username_preview.setText(username)
- self._username_input.setText(username)
-
- def _set_api_key(self, api_key):
- if not api_key or len(api_key) < 3:
- self._api_preview.setText(api_key or "")
- return
-
- api_key_len = len(api_key)
- offset = 6
- if api_key_len < offset:
- offset = api_key_len // 2
- api_key = api_key[:offset] + "." * (api_key_len - offset)
-
- self._api_preview.setText(api_key)
-
- def set_logged_in(
- self,
- logged_in,
- url=None,
- username=None,
- api_key=None,
- allow_logout=None
- ):
- if url is not None:
- self.set_url(url)
-
- if username is not None:
- self.set_username(username)
-
- if api_key:
- self._set_api_key(api_key)
-
- if logged_in and allow_logout is None:
- allow_logout = True
-
- self._set_logged_in(logged_in)
-
- if allow_logout:
- self.set_allow_logout(True)
- elif allow_logout is False:
- self.set_allow_logout(False)
-
-
-def ask_to_login(url=None, username=None, always_on_top=False):
- """Ask user to login using Qt dialog.
-
- Function creates new QApplication if is not created yet.
-
- Args:
- url (Optional[str]): Server url that will be prefilled in dialog.
- username (Optional[str]): Username that will be prefilled in dialog.
- always_on_top (Optional[bool]): Window will be drawn on top of
- other windows.
-
- Returns:
- tuple[str, str, str]: Returns Url, user's token and username. Url can
- be changed during dialog lifetime that's why the url is returned.
- """
-
- app_instance = get_qt_app()
-
- window = ServerLoginWindow()
- if always_on_top:
- window.setWindowFlags(
- window.windowFlags()
- | QtCore.Qt.WindowStaysOnTopHint
- )
-
- if url:
- window.set_url(url)
-
- if username:
- window.set_username(username)
-
- if not app_instance.startingUp():
- window.exec_()
- else:
- window.open()
- app_instance.exec_()
- result = window.result()
- out_url, out_token, out_username, _ = result
- return out_url, out_token, out_username
-
-
-def change_user(url, username, api_key, always_on_top=False):
- """Ask user to login using Qt dialog.
-
- Function creates new QApplication if is not created yet.
-
- Args:
- url (str): Server url that will be prefilled in dialog.
- username (str): Username that will be prefilled in dialog.
- api_key (str): API key that will be prefilled in dialog.
- always_on_top (Optional[bool]): Window will be drawn on top of
- other windows.
-
- Returns:
- Tuple[str, str]: Returns Url and user's token. Url can be changed
- during dialog lifetime that's why the url is returned.
- """
-
- app_instance = get_qt_app()
- window = ServerLoginWindow()
- if always_on_top:
- window.setWindowFlags(
- window.windowFlags()
- | QtCore.Qt.WindowStaysOnTopHint
- )
- window.set_logged_in(True, url, username, api_key)
-
- if not app_instance.startingUp():
- window.exec_()
- else:
- window.open()
- # This can become main Qt loop. Maybe should live elsewhere
- app_instance.exec_()
- return window.result()
diff --git a/common/ayon_common/connection/ui/widgets.py b/common/ayon_common/connection/ui/widgets.py
deleted file mode 100644
index 78b73e056d7..00000000000
--- a/common/ayon_common/connection/ui/widgets.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from qtpy import QtWidgets, QtCore, QtGui
-
-
-class PressHoverButton(QtWidgets.QPushButton):
- """Keep track about mouse press/release and enter/leave."""
-
- _mouse_pressed = False
- _mouse_hovered = False
- change_state = QtCore.Signal(bool)
-
- def mousePressEvent(self, event):
- self._mouse_pressed = True
- self._mouse_hovered = True
- self.change_state.emit(self._mouse_hovered)
- super(PressHoverButton, self).mousePressEvent(event)
-
- def mouseReleaseEvent(self, event):
- self._mouse_pressed = False
- self._mouse_hovered = False
- self.change_state.emit(self._mouse_hovered)
- super(PressHoverButton, self).mouseReleaseEvent(event)
-
- def mouseMoveEvent(self, event):
- mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos())
- under_mouse = self.rect().contains(mouse_pos)
- if under_mouse != self._mouse_hovered:
- self._mouse_hovered = under_mouse
- self.change_state.emit(self._mouse_hovered)
-
- super(PressHoverButton, self).mouseMoveEvent(event)
-
-
-class PlaceholderLineEdit(QtWidgets.QLineEdit):
- """Set placeholder color of QLineEdit in Qt 5.12 and higher."""
-
- def __init__(self, *args, **kwargs):
- super(PlaceholderLineEdit, self).__init__(*args, **kwargs)
- # Change placeholder palette color
- if hasattr(QtGui.QPalette, "PlaceholderText"):
- filter_palette = self.palette()
- color = QtGui.QColor("#D3D8DE")
- color.setAlpha(67)
- filter_palette.setColor(
- QtGui.QPalette.PlaceholderText,
- color
- )
- self.setPalette(filter_palette)
diff --git a/common/ayon_common/distribution/README.md b/common/ayon_common/distribution/README.md
deleted file mode 100644
index f1c34ba7223..00000000000
--- a/common/ayon_common/distribution/README.md
+++ /dev/null
@@ -1,18 +0,0 @@
-Addon distribution tool
-------------------------
-
-Code in this folder is backend portion of Addon distribution logic for v4 server.
-
-Each host, module will be separate Addon in the future. Each v4 server could run different set of Addons.
-
-Client (running on artist machine) will in the first step ask v4 for list of enabled addons.
-(It expects list of json documents matching to `addon_distribution.py:AddonInfo` object.)
-Next it will compare presence of enabled addon version in local folder. In the case of missing version of
-an addon, client will use information in the addon to download (from http/shared local disk/git) zip file
-and unzip it.
-
-Required part of addon distribution will be sharing of dependencies (python libraries, utilities) which is not part of this folder.
-
-Location of this folder might change in the future as it will be required for a clint to add this folder to sys.path reliably.
-
-This code needs to be independent on Openpype code as much as possible!
diff --git a/common/ayon_common/distribution/__init__.py b/common/ayon_common/distribution/__init__.py
deleted file mode 100644
index e3c0f0e1618..00000000000
--- a/common/ayon_common/distribution/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from .control import AyonDistribution, BundleNotFoundError
-from .utils import show_missing_bundle_information
-
-
-__all__ = (
- "AyonDistribution",
- "BundleNotFoundError",
- "show_missing_bundle_information",
-)
diff --git a/common/ayon_common/distribution/control.py b/common/ayon_common/distribution/control.py
deleted file mode 100644
index 95c221d753d..00000000000
--- a/common/ayon_common/distribution/control.py
+++ /dev/null
@@ -1,1116 +0,0 @@
-import os
-import sys
-import json
-import traceback
-import collections
-import datetime
-import logging
-import shutil
-import threading
-import platform
-import attr
-from enum import Enum
-
-import ayon_api
-
-from ayon_common.utils import is_staging_enabled
-
-from .utils import (
- get_addons_dir,
- get_dependencies_dir,
-)
-from .downloaders import get_default_download_factory
-from .data_structures import (
- AddonInfo,
- DependencyItem,
- Bundle,
-)
-
-NOT_SET = type("UNKNOWN", (), {"__bool__": lambda: False})()
-
-
-class BundleNotFoundError(Exception):
- """Bundle name is defined but is not available on server.
-
- Args:
- bundle_name (str): Name of bundle that was not found.
- """
-
- def __init__(self, bundle_name):
- self.bundle_name = bundle_name
- super().__init__(
- f"Bundle '{bundle_name}' is not available on server"
- )
-
-
-class UpdateState(Enum):
- UNKNOWN = "unknown"
- UPDATED = "udated"
- OUTDATED = "outdated"
- UPDATE_FAILED = "failed"
- MISS_SOURCE_FILES = "miss_source_files"
-
-
-class DistributeTransferProgress:
- """Progress of single source item in 'DistributionItem'.
-
- The item is to keep track of single source item.
- """
-
- def __init__(self):
- self._transfer_progress = ayon_api.TransferProgress()
- self._started = False
- self._failed = False
- self._fail_reason = None
- self._unzip_started = False
- self._unzip_finished = False
- self._hash_check_started = False
- self._hash_check_finished = False
-
- def set_started(self):
- """Call when source distribution starts."""
-
- self._started = True
-
- def set_failed(self, reason):
- """Set source distribution as failed.
-
- Args:
- reason (str): Error message why the transfer failed.
- """
-
- self._failed = True
- self._fail_reason = reason
-
- def set_hash_check_started(self):
- """Call just before hash check starts."""
-
- self._hash_check_started = True
-
- def set_hash_check_finished(self):
- """Call just after hash check finishes."""
-
- self._hash_check_finished = True
-
- def set_unzip_started(self):
- """Call just before unzip starts."""
-
- self._unzip_started = True
-
- def set_unzip_finished(self):
- """Call just after unzip finishes."""
-
- self._unzip_finished = True
-
- @property
- def is_running(self):
- """Source distribution is in progress.
-
- Returns:
- bool: Transfer is in progress.
- """
-
- return bool(
- self._started
- and not self._failed
- and not self._hash_check_finished
- )
-
- @property
- def transfer_progress(self):
- """Source file 'download' progress tracker.
-
- Returns:
- ayon_api.TransferProgress.: Content download progress.
- """
-
- return self._transfer_progress
-
- @property
- def started(self):
- return self._started
-
- @property
- def hash_check_started(self):
- return self._hash_check_started
-
- @property
- def hash_check_finished(self):
- return self._has_check_finished
-
- @property
- def unzip_started(self):
- return self._unzip_started
-
- @property
- def unzip_finished(self):
- return self._unzip_finished
-
- @property
- def failed(self):
- return self._failed or self._transfer_progress.failed
-
- @property
- def fail_reason(self):
- return self._fail_reason or self._transfer_progress.fail_reason
-
-
-class DistributionItem:
- """Distribution item with sources and target directories.
-
- Distribution item can be an addon or dependency package. Distribution item
- can be already distributed and don't need any progression. The item keeps
- track of the progress. The reason is to be able to use the distribution
- items as source data for UI without implementing the same logic.
-
- Distribution is "state" based. Distribution can be 'UPDATED' or 'OUTDATED'
- at the initialization. If item is 'UPDATED' the distribution is skipped
- and 'OUTDATED' will trigger the distribution process.
-
- Because the distribution may have multiple sources each source has own
- progress item.
-
- Args:
- state (UpdateState): Initial state (UpdateState.UPDATED or
- UpdateState.OUTDATED).
- unzip_dirpath (str): Path to directory where zip is downloaded.
- download_dirpath (str): Path to directory where file is unzipped.
- file_hash (str): Hash of file for validation.
- factory (DownloadFactory): Downloaders factory object.
- sources (List[SourceInfo]): Possible sources to receive the
- distribution item.
- downloader_data (Dict[str, Any]): More information for downloaders.
- item_label (str): Label used in log outputs (and in UI).
- logger (logging.Logger): Logger object.
- """
-
- def __init__(
- self,
- state,
- unzip_dirpath,
- download_dirpath,
- file_hash,
- factory,
- sources,
- downloader_data,
- item_label,
- logger=None,
- ):
- if logger is None:
- logger = logging.getLogger(self.__class__.__name__)
- self.log = logger
- self.state = state
- self.unzip_dirpath = unzip_dirpath
- self.download_dirpath = download_dirpath
- self.file_hash = file_hash
- self.factory = factory
- self.sources = [
- (source, DistributeTransferProgress())
- for source in sources
- ]
- self.downloader_data = downloader_data
- self.item_label = item_label
-
- self._need_distribution = state != UpdateState.UPDATED
- self._current_source_progress = None
- self._used_source_progress = None
- self._used_source = None
- self._dist_started = False
- self._dist_finished = False
-
- self._error_msg = None
- self._error_detail = None
-
- @property
- def need_distribution(self):
- """Need distribution based on initial state.
-
- Returns:
- bool: Need distribution.
- """
-
- return self._need_distribution
-
- @property
- def current_source_progress(self):
- """Currently processed source progress object.
-
- Returns:
- Union[DistributeTransferProgress, None]: Transfer progress or None.
- """
-
- return self._current_source_progress
-
- @property
- def used_source_progress(self):
- """Transfer progress that successfully distributed the item.
-
- Returns:
- Union[DistributeTransferProgress, None]: Transfer progress or None.
- """
-
- return self._used_source_progress
-
- @property
- def used_source(self):
- """Data of source item.
-
- Returns:
- Union[Dict[str, Any], None]: SourceInfo data or None.
- """
-
- return self._used_source
-
- @property
- def error_message(self):
- """Reason why distribution item failed.
-
- Returns:
- Union[str, None]: Error message.
- """
-
- return self._error_msg
-
- @property
- def error_detail(self):
- """Detailed reason why distribution item failed.
-
- Returns:
- Union[str, None]: Detailed information (maybe traceback).
- """
-
- return self._error_detail
-
- def _distribute(self):
- if not self.sources:
- message = (
- f"{self.item_label}: Don't have"
- " any sources to download from."
- )
- self.log.error(message)
- self._error_msg = message
- self.state = UpdateState.MISS_SOURCE_FILES
- return
-
- download_dirpath = self.download_dirpath
- unzip_dirpath = self.unzip_dirpath
- for source, source_progress in self.sources:
- self._current_source_progress = source_progress
- source_progress.set_started()
-
- # Remove directory if exists
- if os.path.isdir(unzip_dirpath):
- self.log.debug(f"Cleaning {unzip_dirpath}")
- shutil.rmtree(unzip_dirpath)
-
- # Create directory
- os.makedirs(unzip_dirpath)
- if not os.path.isdir(download_dirpath):
- os.makedirs(download_dirpath)
-
- try:
- downloader = self.factory.get_downloader(source.type)
- except Exception:
- message = f"Unknown downloader {source.type}"
- source_progress.set_failed(message)
- self.log.warning(message, exc_info=True)
- continue
-
- source_data = attr.asdict(source)
- cleanup_args = (
- source_data,
- download_dirpath,
- self.downloader_data
- )
-
- try:
- zip_filepath = downloader.download(
- source_data,
- download_dirpath,
- self.downloader_data,
- source_progress.transfer_progress,
- )
- except Exception:
- message = "Failed to download source"
- source_progress.set_failed(message)
- self.log.warning(
- f"{self.item_label}: {message}",
- exc_info=True
- )
- downloader.cleanup(*cleanup_args)
- continue
-
- source_progress.set_hash_check_started()
- try:
- downloader.check_hash(zip_filepath, self.file_hash)
- except Exception:
- message = "File hash does not match"
- source_progress.set_failed(message)
- self.log.warning(
- f"{self.item_label}: {message}",
- exc_info=True
- )
- downloader.cleanup(*cleanup_args)
- continue
-
- source_progress.set_hash_check_finished()
- source_progress.set_unzip_started()
- try:
- downloader.unzip(zip_filepath, unzip_dirpath)
- except Exception:
- message = "Couldn't unzip source file"
- source_progress.set_failed(message)
- self.log.warning(
- f"{self.item_label}: {message}",
- exc_info=True
- )
- downloader.cleanup(*cleanup_args)
- continue
-
- source_progress.set_unzip_finished()
- downloader.cleanup(*cleanup_args)
- self.state = UpdateState.UPDATED
- self._used_source = source_data
- break
-
- last_progress = self._current_source_progress
- self._current_source_progress = None
- if self.state == UpdateState.UPDATED:
- self._used_source_progress = last_progress
- self.log.info(f"{self.item_label}: Distributed")
- return
-
- self.log.error(f"{self.item_label}: Failed to distribute")
- self._error_msg = "Failed to receive or install source files"
-
- def distribute(self):
- """Execute distribution logic."""
-
- if not self.need_distribution or self._dist_started:
- return
-
- self._dist_started = True
- try:
- if self.state == UpdateState.OUTDATED:
- self._distribute()
-
- except Exception as exc:
- self.state = UpdateState.UPDATE_FAILED
- self._error_msg = str(exc)
- self._error_detail = "".join(
- traceback.format_exception(*sys.exc_info())
- )
- self.log.error(
- f"{self.item_label}: Distibution filed",
- exc_info=True
- )
-
- finally:
- self._dist_finished = True
- if self.state == UpdateState.OUTDATED:
- self.state = UpdateState.UPDATE_FAILED
- self._error_msg = "Distribution failed"
-
- if (
- self.state != UpdateState.UPDATED
- and self.unzip_dirpath
- and os.path.isdir(self.unzip_dirpath)
- ):
- self.log.debug(f"Cleaning {self.unzip_dirpath}")
- shutil.rmtree(self.unzip_dirpath)
-
-
-class AyonDistribution:
- """Distribution control.
-
- Receive information from server what addons and dependency packages
- should be available locally and prepare/validate their distribution.
-
- Arguments are available for testing of the class.
-
- Args:
- addon_dirpath (Optional[str]): Where addons will be stored.
- dependency_dirpath (Optional[str]): Where dependencies will be stored.
- dist_factory (Optional[DownloadFactory]): Factory which cares about
- downloading of items based on source type.
- addons_info (Optional[list[dict[str, Any]]): List of prepared
- addons' info.
- dependency_packages_info (Optional[list[dict[str, Any]]): Info
- about packages from server.
- bundles_info (Optional[Dict[str, Any]]): Info about
- bundles.
- bundle_name (Optional[str]): Name of bundle to use. If not passed
- an environment variable 'AYON_BUNDLE_NAME' is checked for value.
- When both are not available the bundle is defined by 'use_staging'
- value.
- use_staging (Optional[bool]): Use staging versions of an addon.
- If not passed, 'is_staging_enabled' is used as default value.
- """
-
- def __init__(
- self,
- addon_dirpath=None,
- dependency_dirpath=None,
- dist_factory=None,
- addons_info=NOT_SET,
- dependency_packages_info=NOT_SET,
- bundles_info=NOT_SET,
- bundle_name=NOT_SET,
- use_staging=None
- ):
- self._log = None
-
- self._dist_started = False
- self._dist_finished = False
-
- self._addons_dirpath = addon_dirpath or get_addons_dir()
- self._dependency_dirpath = dependency_dirpath or get_dependencies_dir()
- self._dist_factory = (
- dist_factory or get_default_download_factory()
- )
-
- if bundle_name is NOT_SET:
- bundle_name = os.environ.get("AYON_BUNDLE_NAME", NOT_SET)
-
- # Raw addons data from server
- self._addons_info = addons_info
- # Prepared data as Addon objects
- self._addon_items = NOT_SET
- # Distrubtion items of addons
- # - only those addons and versions that should be distributed
- self._addon_dist_items = NOT_SET
-
- # Raw dependency packages data from server
- self._dependency_packages_info = dependency_packages_info
- # Prepared dependency packages as objects
- self._dependency_packages_items = NOT_SET
- # Dependency package item that should be used
- self._dependency_package_item = NOT_SET
- # Distribution item of dependency package
- self._dependency_dist_item = NOT_SET
-
- # Raw bundles data from server
- self._bundles_info = bundles_info
- # Bundles as objects
- self._bundle_items = NOT_SET
-
- # Bundle that should be used in production
- self._production_bundle = NOT_SET
- # Bundle that should be used in staging
- self._staging_bundle = NOT_SET
- # Boolean that defines if staging bundle should be used
- self._use_staging = use_staging
-
- # Specific bundle name should be used
- self._bundle_name = bundle_name
- # Final bundle that will be used
- self._bundle = NOT_SET
-
- @property
- def use_staging(self):
- """Staging version of a bundle should be used.
-
- This value is completely ignored if specific bundle name should
- be used.
-
- Returns:
- bool: True if staging version should be used.
- """
-
- if self._use_staging is None:
- self._use_staging = is_staging_enabled()
- return self._use_staging
-
- @property
- def log(self):
- """Helper to access logger.
-
- Returns:
- logging.Logger: Logger instance.
- """
- if self._log is None:
- self._log = logging.getLogger(self.__class__.__name__)
- return self._log
-
- @property
- def bundles_info(self):
- """
-
- Returns:
- dict[str, dict[str, Any]]: Bundles information from server.
- """
-
- if self._bundles_info is NOT_SET:
- self._bundles_info = ayon_api.get_bundles()
- return self._bundles_info
-
- @property
- def bundle_items(self):
- """
-
- Returns:
- list[Bundle]: List of bundles info.
- """
-
- if self._bundle_items is NOT_SET:
- self._bundle_items = [
- Bundle.from_dict(info)
- for info in self.bundles_info["bundles"]
- ]
- return self._bundle_items
-
- def _prepare_production_staging_bundles(self):
- production_bundle = None
- staging_bundle = None
- for bundle in self.bundle_items:
- if bundle.is_production:
- production_bundle = bundle
- if bundle.is_staging:
- staging_bundle = bundle
- self._production_bundle = production_bundle
- self._staging_bundle = staging_bundle
-
- @property
- def production_bundle(self):
- """
- Returns:
- Union[Bundle, None]: Bundle that should be used in production.
- """
-
- if self._production_bundle is NOT_SET:
- self._prepare_production_staging_bundles()
- return self._production_bundle
-
- @property
- def staging_bundle(self):
- """
- Returns:
- Union[Bundle, None]: Bundle that should be used in staging.
- """
-
- if self._staging_bundle is NOT_SET:
- self._prepare_production_staging_bundles()
- return self._staging_bundle
-
- @property
- def bundle_to_use(self):
- """Bundle that will be used for distribution.
-
- Bundle that should be used can be affected by 'bundle_name'
- or 'use_staging'.
-
- Returns:
- Union[Bundle, None]: Bundle that will be used for distribution
- or None.
-
- Raises:
- BundleNotFoundError: When bundle name to use is defined
- but is not available on server.
- """
-
- if self._bundle is NOT_SET:
- if self._bundle_name is not NOT_SET:
- bundle = next(
- (
- bundle
- for bundle in self.bundle_items
- if bundle.name == self._bundle_name
- ),
- None
- )
- if bundle is None:
- raise BundleNotFoundError(self._bundle_name)
-
- self._bundle = bundle
- elif self.use_staging:
- self._bundle = self.staging_bundle
- else:
- self._bundle = self.production_bundle
- return self._bundle
-
- @property
- def bundle_name_to_use(self):
- bundle = self.bundle_to_use
- return None if bundle is None else bundle.name
-
- @property
- def addons_info(self):
- """Server information about available addons.
-
- Returns:
- Dict[str, dict[str, Any]: Addon info by addon name.
- """
-
- if self._addons_info is NOT_SET:
- server_info = ayon_api.get_addons_info(details=True)
- self._addons_info = server_info["addons"]
- return self._addons_info
-
- @property
- def addon_items(self):
- """Information about available addons on server.
-
- Addons may require distribution of files. For those addons will be
- created 'DistributionItem' handling distribution itself.
-
- Returns:
- Dict[str, AddonInfo]: Addon info object by addon name.
- """
-
- if self._addon_items is NOT_SET:
- addons_info = {}
- for addon in self.addons_info:
- addon_info = AddonInfo.from_dict(addon)
- addons_info[addon_info.name] = addon_info
- self._addon_items = addons_info
- return self._addon_items
-
- @property
- def dependency_packages_info(self):
- """Server information about available dependency packages.
-
- Notes:
- For testing purposes it is possible to pass dependency packages
- information to '__init__'.
-
- Returns:
- list[dict[str, Any]]: Dependency packages information.
- """
-
- if self._dependency_packages_info is NOT_SET:
- self._dependency_packages_info = (
- ayon_api.get_dependency_packages())["packages"]
- return self._dependency_packages_info
-
- @property
- def dependency_packages_items(self):
- """Dependency packages as objects.
-
- Returns:
- dict[str, DependencyItem]: Dependency packages as objects by name.
- """
-
- if self._dependency_packages_items is NOT_SET:
- dependenc_package_items = {}
- for item in self.dependency_packages_info:
- item = DependencyItem.from_dict(item)
- dependenc_package_items[item.name] = item
- self._dependency_packages_items = dependenc_package_items
- return self._dependency_packages_items
-
- @property
- def dependency_package_item(self):
- """Dependency package item that should be used by bundle.
-
- Returns:
- Union[None, Dict[str, Any]]: None if bundle does not have
- specified dependency package.
- """
-
- if self._dependency_package_item is NOT_SET:
- dependency_package_item = None
- bundle = self.bundle_to_use
- if bundle is not None:
- package_name = bundle.dependency_packages.get(
- platform.system().lower()
- )
- dependency_package_item = self.dependency_packages_items.get(
- package_name)
- self._dependency_package_item = dependency_package_item
- return self._dependency_package_item
-
- def _prepare_current_addon_dist_items(self):
- addons_metadata = self.get_addons_metadata()
- output = []
- addon_versions = {}
- bundle = self.bundle_to_use
- if bundle is not None:
- addon_versions = bundle.addon_versions
- for addon_name, addon_item in self.addon_items.items():
- addon_version = addon_versions.get(addon_name)
- # Addon is not in bundle -> Skip
- if addon_version is None:
- continue
-
- addon_version_item = addon_item.versions.get(addon_version)
- # Addon version is not available in addons info
- # - TODO handle this case (raise error, skip, store, report, ...)
- if addon_version_item is None:
- print(
- f"Version '{addon_version}' of addon '{addon_name}'"
- " is not available on server."
- )
- continue
-
- if not addon_version_item.require_distribution:
- continue
- full_name = addon_version_item.full_name
- addon_dest = os.path.join(self._addons_dirpath, full_name)
- self.log.debug(f"Checking {full_name} in {addon_dest}")
- addon_in_metadata = (
- addon_name in addons_metadata
- and addon_version_item.version in addons_metadata[addon_name]
- )
- if addon_in_metadata and os.path.isdir(addon_dest):
- self.log.debug(
- f"Addon version folder {addon_dest} already exists."
- )
- state = UpdateState.UPDATED
-
- else:
- state = UpdateState.OUTDATED
-
- downloader_data = {
- "type": "addon",
- "name": addon_name,
- "version": addon_version
- }
-
- dist_item = DistributionItem(
- state,
- addon_dest,
- addon_dest,
- addon_version_item.hash,
- self._dist_factory,
- list(addon_version_item.sources),
- downloader_data,
- full_name,
- self.log
- )
- output.append({
- "dist_item": dist_item,
- "addon_name": addon_name,
- "addon_version": addon_version,
- "addon_item": addon_item,
- "addon_version_item": addon_version_item,
- })
- return output
-
- def _prepare_dependency_progress(self):
- package = self.dependency_package_item
- if package is None:
- return None
-
- metadata = self.get_dependency_metadata()
- downloader_data = {
- "type": "dependency_package",
- "name": package.name,
- "platform": package.platform_name
- }
- zip_dir = package_dir = os.path.join(
- self._dependency_dirpath, package.name
- )
- self.log.debug(f"Checking {package.name} in {package_dir}")
-
- if not os.path.isdir(package_dir) or package.name not in metadata:
- state = UpdateState.OUTDATED
- else:
- state = UpdateState.UPDATED
-
- return DistributionItem(
- state,
- zip_dir,
- package_dir,
- package.checksum,
- self._dist_factory,
- package.sources,
- downloader_data,
- package.name,
- self.log,
- )
-
- def get_addon_dist_items(self):
- """Addon distribution items.
-
- These items describe source files required by addon to be available on
- machine. Each item may have 0-n source information from where can be
- obtained. If file is already available it's state will be 'UPDATED'.
-
- Example output:
- [
- {
- "dist_item": DistributionItem,
- "addon_name": str,
- "addon_version": str,
- "addon_item": AddonInfo,
- "addon_version_item": AddonVersionInfo
- }, {
- ...
- }
- ]
-
- Returns:
- list[dict[str, Any]]: Distribution items with addon version item.
- """
-
- if self._addon_dist_items is NOT_SET:
- self._addon_dist_items = (
- self._prepare_current_addon_dist_items())
- return self._addon_dist_items
-
- def get_dependency_dist_item(self):
- """Dependency package distribution item.
-
- Item describe source files required by server to be available on
- machine. Item may have 0-n source information from where can be
- obtained. If file is already available it's state will be 'UPDATED'.
-
- 'None' is returned if server does not have defined any dependency
- package.
-
- Returns:
- Union[None, DistributionItem]: Dependency item or None if server
- does not have specified any dependency package.
- """
-
- if self._dependency_dist_item is NOT_SET:
- self._dependency_dist_item = self._prepare_dependency_progress()
- return self._dependency_dist_item
-
- def get_dependency_metadata_filepath(self):
- """Path to distribution metadata file.
-
- Metadata contain information about distributed packages, used source,
- expected file hash and time when file was distributed.
-
- Returns:
- str: Path to a file where dependency package metadata are stored.
- """
-
- return os.path.join(self._dependency_dirpath, "dependency.json")
-
- def get_addons_metadata_filepath(self):
- """Path to addons metadata file.
-
- Metadata contain information about distributed addons, used sources,
- expected file hashes and time when files were distributed.
-
- Returns:
- str: Path to a file where addons metadata are stored.
- """
-
- return os.path.join(self._addons_dirpath, "addons.json")
-
- def read_metadata_file(self, filepath, default_value=None):
- """Read json file from path.
-
- Method creates the file when does not exist with default value.
-
- Args:
- filepath (str): Path to json file.
- default_value (Union[Dict[str, Any], List[Any], None]): Default
- value if the file is not available (or valid).
-
- Returns:
- Union[Dict[str, Any], List[Any]]: Value from file.
- """
-
- if default_value is None:
- default_value = {}
-
- if not os.path.exists(filepath):
- return default_value
-
- try:
- with open(filepath, "r") as stream:
- data = json.load(stream)
- except ValueError:
- data = default_value
- return data
-
- def save_metadata_file(self, filepath, data):
- """Store data to json file.
-
- Method creates the file when does not exist.
-
- Args:
- filepath (str): Path to json file.
- data (Union[Dict[str, Any], List[Any]]): Data to store into file.
- """
-
- if not os.path.exists(filepath):
- dirpath = os.path.dirname(filepath)
- if not os.path.exists(dirpath):
- os.makedirs(dirpath)
- with open(filepath, "w") as stream:
- json.dump(data, stream, indent=4)
-
- def get_dependency_metadata(self):
- filepath = self.get_dependency_metadata_filepath()
- return self.read_metadata_file(filepath, {})
-
- def update_dependency_metadata(self, package_name, data):
- dependency_metadata = self.get_dependency_metadata()
- dependency_metadata[package_name] = data
- filepath = self.get_dependency_metadata_filepath()
- self.save_metadata_file(filepath, dependency_metadata)
-
- def get_addons_metadata(self):
- filepath = self.get_addons_metadata_filepath()
- return self.read_metadata_file(filepath, {})
-
- def update_addons_metadata(self, addons_information):
- if not addons_information:
- return
- addons_metadata = self.get_addons_metadata()
- for addon_name, version_value in addons_information.items():
- if addon_name not in addons_metadata:
- addons_metadata[addon_name] = {}
- for addon_version, version_data in version_value.items():
- addons_metadata[addon_name][addon_version] = version_data
-
- filepath = self.get_addons_metadata_filepath()
- self.save_metadata_file(filepath, addons_metadata)
-
- def finish_distribution(self):
- """Store metadata about distributed items."""
-
- self._dist_finished = True
- stored_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- dependency_dist_item = self.get_dependency_dist_item()
- if (
- dependency_dist_item is not None
- and dependency_dist_item.need_distribution
- and dependency_dist_item.state == UpdateState.UPDATED
- ):
- package = self.dependency_package
- source = dependency_dist_item.used_source
- if source is not None:
- data = {
- "source": source,
- "file_hash": dependency_dist_item.file_hash,
- "distributed_dt": stored_time
- }
- self.update_dependency_metadata(package.name, data)
-
- addons_info = {}
- for item in self.get_addon_dist_items():
- dist_item = item["dist_item"]
- if (
- not dist_item.need_distribution
- or dist_item.state != UpdateState.UPDATED
- ):
- continue
-
- source_data = dist_item.used_source
- if not source_data:
- continue
-
- addon_name = item["addon_name"]
- addon_version = item["addon_version"]
- addons_info.setdefault(addon_name, {})
- addons_info[addon_name][addon_version] = {
- "source": source_data,
- "file_hash": dist_item.file_hash,
- "distributed_dt": stored_time
- }
-
- self.update_addons_metadata(addons_info)
-
- def get_all_distribution_items(self):
- """Distribution items required by server.
-
- Items contain dependency package item and all addons that are enabled
- and have distribution requirements.
-
- Items can be already available on machine.
-
- Returns:
- List[DistributionItem]: Distribution items required by server.
- """
-
- output = [
- item["dist_item"]
- for item in self.get_addon_dist_items()
- ]
- dependency_dist_item = self.get_dependency_dist_item()
- if dependency_dist_item is not None:
- output.insert(0, dependency_dist_item)
-
- return output
-
- def distribute(self, threaded=False):
- """Distribute all missing items.
-
- Method will try to distribute all items that are required by server.
-
- This method does not handle failed items. To validate the result call
- 'validate_distribution' when this method finishes.
-
- Args:
- threaded (bool): Distribute items in threads.
- """
-
- if self._dist_started:
- raise RuntimeError("Distribution already started")
- self._dist_started = True
- threads = collections.deque()
- for item in self.get_all_distribution_items():
- if threaded:
- threads.append(threading.Thread(target=item.distribute))
- else:
- item.distribute()
-
- while threads:
- thread = threads.popleft()
- if thread.is_alive():
- threads.append(thread)
- else:
- thread.join()
-
- self.finish_distribution()
-
- def validate_distribution(self):
- """Check if all required distribution items are distributed.
-
- Raises:
- RuntimeError: Any of items is not available.
- """
-
- invalid = []
- dependency_package = self.get_dependency_dist_item()
- if (
- dependency_package is not None
- and dependency_package.state != UpdateState.UPDATED
- ):
- invalid.append("Dependency package")
-
- for item in self.get_addon_dist_items():
- dist_item = item["dist_item"]
- if dist_item.state != UpdateState.UPDATED:
- invalid.append(item["addon_name"])
-
- if not invalid:
- return
-
- raise RuntimeError("Failed to distribute {}".format(
- ", ".join([f'"{item}"' for item in invalid])
- ))
-
- def get_sys_paths(self):
- """Get all paths to python packages that should be added to python.
-
- These paths lead to addon directories and python dependencies in
- dependency package.
-
- Todos:
- Add dependency package directory to output. ATM is not structure of
- dependency package 100% defined.
-
- Returns:
- List[str]: Paths that should be added to 'sys.path' and
- 'PYTHONPATH'.
- """
-
- output = []
- for item in self.get_all_distribution_items():
- if item.state != UpdateState.UPDATED:
- continue
- unzip_dirpath = item.unzip_dirpath
- if unzip_dirpath and os.path.exists(unzip_dirpath):
- output.append(unzip_dirpath)
- return output
-
-
-def cli(*args):
- raise NotImplementedError
diff --git a/common/ayon_common/distribution/data_structures.py b/common/ayon_common/distribution/data_structures.py
deleted file mode 100644
index aa93d4ed714..00000000000
--- a/common/ayon_common/distribution/data_structures.py
+++ /dev/null
@@ -1,265 +0,0 @@
-import attr
-from enum import Enum
-
-
-class UrlType(Enum):
- HTTP = "http"
- GIT = "git"
- FILESYSTEM = "filesystem"
- SERVER = "server"
-
-
-@attr.s
-class MultiPlatformValue(object):
- windows = attr.ib(default=None)
- linux = attr.ib(default=None)
- darwin = attr.ib(default=None)
-
-
-@attr.s
-class SourceInfo(object):
- type = attr.ib()
-
-
-@attr.s
-class LocalSourceInfo(SourceInfo):
- path = attr.ib(default=attr.Factory(MultiPlatformValue))
-
-
-@attr.s
-class WebSourceInfo(SourceInfo):
- url = attr.ib(default=None)
- headers = attr.ib(default=None)
- filename = attr.ib(default=None)
-
-
-@attr.s
-class ServerSourceInfo(SourceInfo):
- filename = attr.ib(default=None)
- path = attr.ib(default=None)
-
-
-def convert_source(source):
- """Create source object from data information.
-
- Args:
- source (Dict[str, any]): Information about source.
-
- Returns:
- Union[None, SourceInfo]: Object with source information if type is
- known.
- """
-
- source_type = source.get("type")
- if not source_type:
- return None
-
- if source_type == UrlType.FILESYSTEM.value:
- return LocalSourceInfo(
- type=source_type,
- path=source["path"]
- )
-
- if source_type == UrlType.HTTP.value:
- url = source["path"]
- return WebSourceInfo(
- type=source_type,
- url=url,
- headers=source.get("headers"),
- filename=source.get("filename")
- )
-
- if source_type == UrlType.SERVER.value:
- return ServerSourceInfo(
- type=source_type,
- filename=source.get("filename"),
- path=source.get("path")
- )
-
-
-def prepare_sources(src_sources):
- sources = []
- unknown_sources = []
- for source in (src_sources or []):
- dependency_source = convert_source(source)
- if dependency_source is not None:
- sources.append(dependency_source)
- else:
- print(f"Unknown source {source.get('type')}")
- unknown_sources.append(source)
- return sources, unknown_sources
-
-
-@attr.s
-class VersionData(object):
- version_data = attr.ib(default=None)
-
-
-@attr.s
-class AddonVersionInfo(object):
- version = attr.ib()
- full_name = attr.ib()
- title = attr.ib(default=None)
- require_distribution = attr.ib(default=False)
- sources = attr.ib(default=attr.Factory(list))
- unknown_sources = attr.ib(default=attr.Factory(list))
- hash = attr.ib(default=None)
-
- @classmethod
- def from_dict(
- cls, addon_name, addon_title, addon_version, version_data
- ):
- """Addon version info.
-
- Args:
- addon_name (str): Name of addon.
- addon_title (str): Title of addon.
- addon_version (str): Version of addon.
- version_data (dict[str, Any]): Addon version information from
- server.
-
- Returns:
- AddonVersionInfo: Addon version info.
- """
-
- full_name = f"{addon_name}_{addon_version}"
- title = f"{addon_title} {addon_version}"
-
- source_info = version_data.get("clientSourceInfo")
- require_distribution = source_info is not None
- sources, unknown_sources = prepare_sources(source_info)
-
- return cls(
- version=addon_version,
- full_name=full_name,
- require_distribution=require_distribution,
- sources=sources,
- unknown_sources=unknown_sources,
- hash=version_data.get("hash"),
- title=title
- )
-
-
-@attr.s
-class AddonInfo(object):
- """Object matching json payload from Server"""
- name = attr.ib()
- versions = attr.ib(default=attr.Factory(dict))
- title = attr.ib(default=None)
- description = attr.ib(default=None)
- license = attr.ib(default=None)
- authors = attr.ib(default=None)
-
- @classmethod
- def from_dict(cls, data):
- """Addon info by available versions.
-
- Args:
- data (dict[str, Any]): Addon information from server. Should
- contain information about every version under 'versions'.
-
- Returns:
- AddonInfo: Addon info with available versions.
- """
-
- # server payload contains info about all versions
- addon_name = data["name"]
- title = data.get("title") or addon_name
-
- src_versions = data.get("versions") or {}
- dst_versions = {
- addon_version: AddonVersionInfo.from_dict(
- addon_name, title, addon_version, version_data
- )
- for addon_version, version_data in src_versions.items()
- }
- return cls(
- name=addon_name,
- versions=dst_versions,
- description=data.get("description"),
- title=data.get("title") or addon_name,
- license=data.get("license"),
- authors=data.get("authors")
- )
-
-
-@attr.s
-class DependencyItem(object):
- """Object matching payload from Server about single dependency package"""
- name = attr.ib()
- platform_name = attr.ib()
- checksum = attr.ib()
- sources = attr.ib(default=attr.Factory(list))
- unknown_sources = attr.ib(default=attr.Factory(list))
- source_addons = attr.ib(default=attr.Factory(dict))
- python_modules = attr.ib(default=attr.Factory(dict))
-
- @classmethod
- def from_dict(cls, package):
- src_sources = package.get("sources") or []
- for source in src_sources:
- if source.get("type") == "server" and not source.get("filename"):
- source["filename"] = package["filename"]
- sources, unknown_sources = prepare_sources(src_sources)
- return cls(
- name=package["filename"],
- platform_name=package["platform"],
- sources=sources,
- unknown_sources=unknown_sources,
- checksum=package["checksum"],
- source_addons=package["sourceAddons"],
- python_modules=package["pythonModules"]
- )
-
-
-@attr.s
-class Installer:
- version = attr.ib()
- filename = attr.ib()
- platform_name = attr.ib()
- size = attr.ib()
- checksum = attr.ib()
- python_version = attr.ib()
- python_modules = attr.ib()
- sources = attr.ib(default=attr.Factory(list))
- unknown_sources = attr.ib(default=attr.Factory(list))
-
- @classmethod
- def from_dict(cls, installer_info):
- sources, unknown_sources = prepare_sources(
- installer_info.get("sources"))
-
- return cls(
- version=installer_info["version"],
- filename=installer_info["filename"],
- platform_name=installer_info["platform"],
- size=installer_info["size"],
- sources=sources,
- unknown_sources=unknown_sources,
- checksum=installer_info["checksum"],
- python_version=installer_info["pythonVersion"],
- python_modules=installer_info["pythonModules"]
- )
-
-
-@attr.s
-class Bundle:
- """Class representing bundle information."""
-
- name = attr.ib()
- installer_version = attr.ib()
- addon_versions = attr.ib(default=attr.Factory(dict))
- dependency_packages = attr.ib(default=attr.Factory(dict))
- is_production = attr.ib(default=False)
- is_staging = attr.ib(default=False)
-
- @classmethod
- def from_dict(cls, data):
- return cls(
- name=data["name"],
- installer_version=data.get("installerVersion"),
- addon_versions=data.get("addons", {}),
- dependency_packages=data.get("dependencyPackages", {}),
- is_production=data["isProduction"],
- is_staging=data["isStaging"],
- )
diff --git a/common/ayon_common/distribution/downloaders.py b/common/ayon_common/distribution/downloaders.py
deleted file mode 100644
index 23280176c32..00000000000
--- a/common/ayon_common/distribution/downloaders.py
+++ /dev/null
@@ -1,250 +0,0 @@
-import os
-import logging
-import platform
-from abc import ABCMeta, abstractmethod
-
-import ayon_api
-
-from .file_handler import RemoteFileHandler
-from .data_structures import UrlType
-
-
-class SourceDownloader(metaclass=ABCMeta):
- """Abstract class for source downloader."""
-
- log = logging.getLogger(__name__)
-
- @classmethod
- @abstractmethod
- def download(cls, source, destination_dir, data, transfer_progress):
- """Returns url of downloaded addon zip file.
-
- Tranfer progress can be ignored, in that case file transfer won't
- be shown as 0-100% but as 'running'. First step should be to set
- destination content size and then add transferred chunk sizes.
-
- Args:
- source (dict): {type:"http", "url":"https://} ...}
- destination_dir (str): local folder to unzip
- data (dict): More information about download content. Always have
- 'type' key in.
- transfer_progress (ayon_api.TransferProgress): Progress of
- transferred (copy/download) content.
-
- Returns:
- (str) local path to addon zip file
- """
-
- pass
-
- @classmethod
- @abstractmethod
- def cleanup(cls, source, destination_dir, data):
- """Cleanup files when distribution finishes or crashes.
-
- Cleanup e.g. temporary files (downloaded zip) or other related stuff
- to downloader.
- """
-
- pass
-
- @classmethod
- def check_hash(cls, addon_path, addon_hash, hash_type="sha256"):
- """Compares 'hash' of downloaded 'addon_url' file.
-
- Args:
- addon_path (str): Local path to addon file.
- addon_hash (str): Hash of downloaded file.
- hash_type (str): Type of hash.
-
- Raises:
- ValueError if hashes doesn't match
- """
-
- if not os.path.exists(addon_path):
- raise ValueError(f"{addon_path} doesn't exist.")
- if not RemoteFileHandler.check_integrity(
- addon_path, addon_hash, hash_type=hash_type
- ):
- raise ValueError(f"{addon_path} doesn't match expected hash.")
-
- @classmethod
- def unzip(cls, addon_zip_path, destination_dir):
- """Unzips local 'addon_zip_path' to 'destination'.
-
- Args:
- addon_zip_path (str): local path to addon zip file
- destination_dir (str): local folder to unzip
- """
-
- RemoteFileHandler.unzip(addon_zip_path, destination_dir)
- os.remove(addon_zip_path)
-
-
-class OSDownloader(SourceDownloader):
- """Downloader using files from file drive."""
-
- @classmethod
- def download(cls, source, destination_dir, data, transfer_progress):
- # OS doesn't need to download, unzip directly
- addon_url = source["path"].get(platform.system().lower())
- if not os.path.exists(addon_url):
- raise ValueError(f"{addon_url} is not accessible")
- return addon_url
-
- @classmethod
- def cleanup(cls, source, destination_dir, data):
- # Nothing to do - download does not copy anything
- pass
-
-
-class HTTPDownloader(SourceDownloader):
- """Downloader using http or https protocol."""
-
- CHUNK_SIZE = 100000
-
- @staticmethod
- def get_filename(source):
- source_url = source["url"]
- filename = source.get("filename")
- if not filename:
- filename = os.path.basename(source_url)
- basename, ext = os.path.splitext(filename)
- allowed_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS)
- if ext.lower().lstrip(".") not in allowed_exts:
- filename = f"{basename}.zip"
- return filename
-
- @classmethod
- def download(cls, source, destination_dir, data, transfer_progress):
- source_url = source["url"]
- cls.log.debug(f"Downloading {source_url} to {destination_dir}")
- headers = source.get("headers")
- filename = cls.get_filename(source)
-
- # TODO use transfer progress
- RemoteFileHandler.download_url(
- source_url,
- destination_dir,
- filename,
- headers=headers
- )
-
- return os.path.join(destination_dir, filename)
-
- @classmethod
- def cleanup(cls, source, destination_dir, data):
- filename = cls.get_filename(source)
- filepath = os.path.join(destination_dir, filename)
- if os.path.exists(filepath) and os.path.isfile(filepath):
- os.remove(filepath)
-
-
-class AyonServerDownloader(SourceDownloader):
- """Downloads static resource file from AYON Server.
-
- Expects filled env var AYON_SERVER_URL.
- """
-
- CHUNK_SIZE = 8192
-
- @classmethod
- def download(cls, source, destination_dir, data, transfer_progress):
- path = source["path"]
- filename = source["filename"]
- if path and not filename:
- filename = path.split("/")[-1]
-
- cls.log.debug(f"Downloading {filename} to {destination_dir}")
-
- _, ext = os.path.splitext(filename)
- ext = ext.lower().lstrip(".")
- valid_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS)
- if ext not in valid_exts:
- raise ValueError((
- f"Invalid file extension \"{ext}\"."
- f" Expected {', '.join(valid_exts)}"
- ))
-
- if path:
- filepath = os.path.join(destination_dir, filename)
- return ayon_api.download_file(
- path,
- filepath,
- chunk_size=cls.CHUNK_SIZE,
- progress=transfer_progress
- )
-
- # dst_filepath = os.path.join(destination_dir, filename)
- if data["type"] == "dependency_package":
- return ayon_api.download_dependency_package(
- data["name"],
- destination_dir,
- filename,
- platform_name=data["platform"],
- chunk_size=cls.CHUNK_SIZE,
- progress=transfer_progress
- )
-
- if data["type"] == "addon":
- return ayon_api.download_addon_private_file(
- data["name"],
- data["version"],
- filename,
- destination_dir,
- chunk_size=cls.CHUNK_SIZE,
- progress=transfer_progress
- )
-
- raise ValueError(f"Unknown type to download \"{data['type']}\"")
-
- @classmethod
- def cleanup(cls, source, destination_dir, data):
- filename = source["filename"]
- filepath = os.path.join(destination_dir, filename)
- if os.path.exists(filepath) and os.path.isfile(filepath):
- os.remove(filepath)
-
-
-class DownloadFactory:
- """Factory for downloaders."""
-
- def __init__(self):
- self._downloaders = {}
-
- def register_format(self, downloader_type, downloader):
- """Register downloader for download type.
-
- Args:
- downloader_type (UrlType): Type of source.
- downloader (SourceDownloader): Downloader which cares about
- download, hash check and unzipping.
- """
-
- self._downloaders[downloader_type.value] = downloader
-
- def get_downloader(self, downloader_type):
- """Registered downloader for type.
-
- Args:
- downloader_type (UrlType): Type of source.
-
- Returns:
- SourceDownloader: Downloader object which should care about file
- distribution.
-
- Raises:
- ValueError: If type does not have registered downloader.
- """
-
- if downloader := self._downloaders.get(downloader_type):
- return downloader()
- raise ValueError(f"{downloader_type} not implemented")
-
-
-def get_default_download_factory():
- download_factory = DownloadFactory()
- download_factory.register_format(UrlType.FILESYSTEM, OSDownloader)
- download_factory.register_format(UrlType.HTTP, HTTPDownloader)
- download_factory.register_format(UrlType.SERVER, AyonServerDownloader)
- return download_factory
diff --git a/common/ayon_common/distribution/tests/test_addon_distributtion.py b/common/ayon_common/distribution/tests/test_addon_distributtion.py
deleted file mode 100644
index 3e7bd1bc6a4..00000000000
--- a/common/ayon_common/distribution/tests/test_addon_distributtion.py
+++ /dev/null
@@ -1,248 +0,0 @@
-import os
-import sys
-import copy
-import tempfile
-
-
-import attr
-import pytest
-
-current_dir = os.path.dirname(os.path.abspath(__file__))
-root_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..", ".."))
-sys.path.append(root_dir)
-
-from common.ayon_common.distribution.downloaders import (
- DownloadFactory,
- OSDownloader,
- HTTPDownloader,
-)
-from common.ayon_common.distribution.control import (
- AyonDistribution,
- UpdateState,
-)
-from common.ayon_common.distribution.data_structures import (
- AddonInfo,
- UrlType,
-)
-
-
-@pytest.fixture
-def download_factory():
- addon_downloader = DownloadFactory()
- addon_downloader.register_format(UrlType.FILESYSTEM, OSDownloader)
- addon_downloader.register_format(UrlType.HTTP, HTTPDownloader)
-
- yield addon_downloader
-
-
-@pytest.fixture
-def http_downloader(download_factory):
- yield download_factory.get_downloader(UrlType.HTTP.value)
-
-
-@pytest.fixture
-def temp_folder():
- yield tempfile.mkdtemp(prefix="ayon_test_")
-
-
-@pytest.fixture
-def sample_bundles():
- yield {
- "bundles": [
- {
- "name": "TestBundle",
- "createdAt": "2023-06-29T00:00:00.0+00:00",
- "installerVersion": None,
- "addons": {
- "slack": "1.0.0"
- },
- "dependencyPackages": {},
- "isProduction": True,
- "isStaging": False
- }
- ],
- "productionBundle": "TestBundle",
- "stagingBundle": None
- }
-
-
-@pytest.fixture
-def sample_addon_info():
- yield {
- "name": "slack",
- "title": "Slack addon",
- "versions": {
- "1.0.0": {
- "hasSettings": True,
- "hasSiteSettings": False,
- "clientPyproject": {
- "tool": {
- "poetry": {
- "dependencies": {
- "nxtools": "^1.6",
- "orjson": "^3.6.7",
- "typer": "^0.4.1",
- "email-validator": "^1.1.3",
- "python": "^3.10",
- "fastapi": "^0.73.0"
- }
- }
- }
- },
- "clientSourceInfo": [
- {
- "type": "http",
- "path": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing", # noqa
- "filename": "dummy.zip"
- },
- {
- "type": "filesystem",
- "path": {
- "windows": "P:/sources/some_file.zip",
- "linux": "/mnt/srv/sources/some_file.zip",
- "darwin": "/Volumes/srv/sources/some_file.zip"
- }
- }
- ],
- "frontendScopes": {
- "project": {
- "sidebar": "hierarchy",
- }
- },
- "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658" # noqa
- }
- },
- "description": ""
- }
-
-
-def test_register(printer):
- download_factory = DownloadFactory()
-
- assert len(download_factory._downloaders) == 0, "Contains registered"
-
- download_factory.register_format(UrlType.FILESYSTEM, OSDownloader)
- assert len(download_factory._downloaders) == 1, "Should contain one"
-
-
-def test_get_downloader(printer, download_factory):
- assert download_factory.get_downloader(UrlType.FILESYSTEM.value), "Should find" # noqa
-
- with pytest.raises(ValueError):
- download_factory.get_downloader("unknown"), "Shouldn't find"
-
-
-def test_addon_info(printer, sample_addon_info):
- """Tests parsing of expected payload from v4 server into AadonInfo."""
- valid_minimum = {
- "name": "slack",
- "versions": {
- "1.0.0": {
- "clientSourceInfo": [
- {
- "type": "filesystem",
- "path": {
- "windows": "P:/sources/some_file.zip",
- "linux": "/mnt/srv/sources/some_file.zip",
- "darwin": "/Volumes/srv/sources/some_file.zip"
- }
- }
- ]
- }
- }
- }
-
- assert AddonInfo.from_dict(valid_minimum), "Missing required fields"
-
- addon = AddonInfo.from_dict(sample_addon_info)
- assert addon, "Should be created"
- assert addon.name == "slack", "Incorrect name"
- assert "1.0.0" in addon.versions, "Version is not in versions"
-
- with pytest.raises(TypeError):
- assert addon["name"], "Dict approach not implemented"
-
- addon_as_dict = attr.asdict(addon)
- assert addon_as_dict["name"], "Dict approach should work"
-
-
-def _get_dist_item(dist_items, name, version):
- final_dist_info = next(
- (
- dist_info
- for dist_info in dist_items
- if (
- dist_info["addon_name"] == name
- and dist_info["addon_version"] == version
- )
- ),
- {}
- )
- return final_dist_info["dist_item"]
-
-
-def test_update_addon_state(
- printer, sample_addon_info, temp_folder, download_factory, sample_bundles
-):
- """Tests possible cases of addon update."""
-
- addon_version = list(sample_addon_info["versions"])[0]
- broken_addon_info = copy.deepcopy(sample_addon_info)
-
- # Cause crash because of invalid hash
- broken_addon_info["versions"][addon_version]["hash"] = "brokenhash"
- distribution = AyonDistribution(
- addon_dirpath=temp_folder,
- dependency_dirpath=temp_folder,
- dist_factory=download_factory,
- addons_info=[broken_addon_info],
- dependency_packages_info=[],
- bundles_info=sample_bundles
- )
- distribution.distribute()
- dist_items = distribution.get_addon_dist_items()
- slack_dist_item = _get_dist_item(
- dist_items,
- sample_addon_info["name"],
- addon_version
- )
- slack_state = slack_dist_item.state
- assert slack_state == UpdateState.UPDATE_FAILED, (
- "Update should have failed because of wrong hash")
-
- # Fix cache and validate if was updated
- distribution = AyonDistribution(
- addon_dirpath=temp_folder,
- dependency_dirpath=temp_folder,
- dist_factory=download_factory,
- addons_info=[sample_addon_info],
- dependency_packages_info=[],
- bundles_info=sample_bundles
- )
- distribution.distribute()
- dist_items = distribution.get_addon_dist_items()
- slack_dist_item = _get_dist_item(
- dist_items,
- sample_addon_info["name"],
- addon_version
- )
- assert slack_dist_item.state == UpdateState.UPDATED, (
- "Addon should have been updated")
-
- # Is UPDATED without calling distribute
- distribution = AyonDistribution(
- addon_dirpath=temp_folder,
- dependency_dirpath=temp_folder,
- dist_factory=download_factory,
- addons_info=[sample_addon_info],
- dependency_packages_info=[],
- bundles_info=sample_bundles
- )
- dist_items = distribution.get_addon_dist_items()
- slack_dist_item = _get_dist_item(
- dist_items,
- sample_addon_info["name"],
- addon_version
- )
- assert slack_dist_item.state == UpdateState.UPDATED, (
- "Addon should already exist")
diff --git a/common/ayon_common/distribution/ui/missing_bundle_window.py b/common/ayon_common/distribution/ui/missing_bundle_window.py
deleted file mode 100644
index ae7a6a2976a..00000000000
--- a/common/ayon_common/distribution/ui/missing_bundle_window.py
+++ /dev/null
@@ -1,146 +0,0 @@
-import sys
-
-from qtpy import QtWidgets, QtGui
-
-from ayon_common import is_staging_enabled
-from ayon_common.resources import (
- get_icon_path,
- load_stylesheet,
-)
-from ayon_common.ui_utils import get_qt_app
-
-
-class MissingBundleWindow(QtWidgets.QDialog):
- default_width = 410
- default_height = 170
-
- def __init__(
- self, url=None, bundle_name=None, use_staging=None, parent=None
- ):
- super().__init__(parent)
-
- icon_path = get_icon_path()
- icon = QtGui.QIcon(icon_path)
- self.setWindowIcon(icon)
- self.setWindowTitle("Missing Bundle")
-
- self._url = url
- self._bundle_name = bundle_name
- self._use_staging = use_staging
- self._first_show = True
-
- info_label = QtWidgets.QLabel("", self)
- info_label.setWordWrap(True)
-
- btns_widget = QtWidgets.QWidget(self)
- confirm_btn = QtWidgets.QPushButton("Exit", btns_widget)
-
- btns_layout = QtWidgets.QHBoxLayout(btns_widget)
- btns_layout.setContentsMargins(0, 0, 0, 0)
- btns_layout.addStretch(1)
- btns_layout.addWidget(confirm_btn, 0)
-
- main_layout = QtWidgets.QVBoxLayout(self)
- main_layout.addWidget(info_label, 0)
- main_layout.addStretch(1)
- main_layout.addWidget(btns_widget, 0)
-
- confirm_btn.clicked.connect(self._on_confirm_click)
-
- self._info_label = info_label
- self._confirm_btn = confirm_btn
-
- self._update_label()
-
- def set_url(self, url):
- if url == self._url:
- return
- self._url = url
- self._update_label()
-
- def set_bundle_name(self, bundle_name):
- if bundle_name == self._bundle_name:
- return
- self._bundle_name = bundle_name
- self._update_label()
-
- def set_use_staging(self, use_staging):
- if self._use_staging == use_staging:
- return
- self._use_staging = use_staging
- self._update_label()
-
- def showEvent(self, event):
- super().showEvent(event)
- if self._first_show:
- self._first_show = False
- self._on_first_show()
- self._recalculate_sizes()
-
- def resizeEvent(self, event):
- super().resizeEvent(event)
- self._recalculate_sizes()
-
- def _recalculate_sizes(self):
- hint = self._confirm_btn.sizeHint()
- new_width = max((hint.width(), hint.height() * 3))
- self._confirm_btn.setMinimumWidth(new_width)
-
- def _on_first_show(self):
- self.setStyleSheet(load_stylesheet())
- self.resize(self.default_width, self.default_height)
-
- def _on_confirm_click(self):
- self.accept()
- self.close()
-
- def _update_label(self):
- self._info_label.setText(self._get_label())
-
- def _get_label(self):
- url_part = f" {self._url}" if self._url else ""
-
- if self._bundle_name:
- return (
- f"Requested release bundle {self._bundle_name}"
- f" is not available on server{url_part}."
- "
Try to restart AYON desktop launcher. Please"
- " contact your administrator if issue persist."
- )
- mode = "staging" if self._use_staging else "production"
- return (
- f"No release bundle is set as {mode} on the AYON"
- f" server{url_part} so there is nothing to launch."
- "
Please contact your administrator"
- " to resolve the issue."
- )
-
-
-def main():
- """Show message that server does not have set bundle to use.
-
- It is possible to pass url as argument to show it in the message. To use
- this feature, pass `--url ` as argument to this script.
- """
-
- url = None
- bundle_name = None
- if "--url" in sys.argv:
- url_index = sys.argv.index("--url") + 1
- if url_index < len(sys.argv):
- url = sys.argv[url_index]
-
- if "--bundle" in sys.argv:
- bundle_index = sys.argv.index("--bundle") + 1
- if bundle_index < len(sys.argv):
- bundle_name = sys.argv[bundle_index]
-
- use_staging = is_staging_enabled()
- app = get_qt_app()
- window = MissingBundleWindow(url, bundle_name, use_staging)
- window.show()
- app.exec_()
-
-
-if __name__ == "__main__":
- main()
diff --git a/common/ayon_common/distribution/utils.py b/common/ayon_common/distribution/utils.py
deleted file mode 100644
index a8b755707af..00000000000
--- a/common/ayon_common/distribution/utils.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import os
-import subprocess
-
-from ayon_common.utils import get_ayon_appdirs, get_ayon_launch_args
-
-
-def get_local_dir(*subdirs):
- """Get product directory in user's home directory.
-
- Each user on machine have own local directory where are downloaded updates,
- addons etc.
-
- Returns:
- str: Path to product local directory.
- """
-
- if not subdirs:
- raise ValueError("Must fill dir_name if nothing else provided!")
-
- local_dir = get_ayon_appdirs(*subdirs)
- if not os.path.isdir(local_dir):
- try:
- os.makedirs(local_dir)
- except Exception: # TODO fix exception
- raise RuntimeError(f"Cannot create {local_dir}")
-
- return local_dir
-
-
-def get_addons_dir():
- """Directory where addon packages are stored.
-
- Path to addons is defined using python module 'appdirs' which
-
- The path is stored into environment variable 'AYON_ADDONS_DIR'.
- Value of environment variable can be overriden, but we highly recommended
- to use that option only for development purposes.
-
- Returns:
- str: Path to directory where addons should be downloaded.
- """
-
- addons_dir = os.environ.get("AYON_ADDONS_DIR")
- if not addons_dir:
- addons_dir = get_local_dir("addons")
- os.environ["AYON_ADDONS_DIR"] = addons_dir
- return addons_dir
-
-
-def get_dependencies_dir():
- """Directory where dependency packages are stored.
-
- Path to addons is defined using python module 'appdirs' which
-
- The path is stored into environment variable 'AYON_DEPENDENCIES_DIR'.
- Value of environment variable can be overriden, but we highly recommended
- to use that option only for development purposes.
-
- Returns:
- str: Path to directory where dependency packages should be downloaded.
- """
-
- dependencies_dir = os.environ.get("AYON_DEPENDENCIES_DIR")
- if not dependencies_dir:
- dependencies_dir = get_local_dir("dependency_packages")
- os.environ["AYON_DEPENDENCIES_DIR"] = dependencies_dir
- return dependencies_dir
-
-
-def show_missing_bundle_information(url, bundle_name=None):
- """Show missing bundle information window.
-
- This function should be called when server does not have set bundle for
- production or staging, or when bundle that should be used is not available
- on server.
-
- Using subprocess to show the dialog. Is blocking and is waiting until
- dialog is closed.
-
- Args:
- url (str): Server url where bundle is not set.
- bundle_name (Optional[str]): Name of bundle that was not found.
- """
-
- ui_dir = os.path.join(os.path.dirname(__file__), "ui")
- script_path = os.path.join(ui_dir, "missing_bundle_window.py")
- args = get_ayon_launch_args(script_path, "--skip-bootstrap", "--url", url)
- if bundle_name:
- args.extend(["--bundle", bundle_name])
- subprocess.call(args)
diff --git a/common/ayon_common/resources/AYON.icns b/common/ayon_common/resources/AYON.icns
deleted file mode 100644
index 2ec66cf3e0b..00000000000
Binary files a/common/ayon_common/resources/AYON.icns and /dev/null differ
diff --git a/common/ayon_common/resources/AYON.ico b/common/ayon_common/resources/AYON.ico
deleted file mode 100644
index e0ec3292f85..00000000000
Binary files a/common/ayon_common/resources/AYON.ico and /dev/null differ
diff --git a/common/ayon_common/resources/AYON.png b/common/ayon_common/resources/AYON.png
deleted file mode 100644
index ed13aeea527..00000000000
Binary files a/common/ayon_common/resources/AYON.png and /dev/null differ
diff --git a/common/ayon_common/resources/AYON_staging.png b/common/ayon_common/resources/AYON_staging.png
deleted file mode 100644
index 75dadfd56c8..00000000000
Binary files a/common/ayon_common/resources/AYON_staging.png and /dev/null differ
diff --git a/common/ayon_common/resources/__init__.py b/common/ayon_common/resources/__init__.py
deleted file mode 100644
index 2b516feff3d..00000000000
--- a/common/ayon_common/resources/__init__.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import os
-
-from ayon_common.utils import is_staging_enabled
-
-RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
-
-
-def get_resource_path(*args):
- path_items = list(args)
- path_items.insert(0, RESOURCES_DIR)
- return os.path.sep.join(path_items)
-
-
-def get_icon_path():
- if is_staging_enabled():
- return get_resource_path("AYON_staging.png")
- return get_resource_path("AYON.png")
-
-
-def load_stylesheet():
- stylesheet_path = get_resource_path("stylesheet.css")
-
- with open(stylesheet_path, "r") as stream:
- content = stream.read()
- return content
diff --git a/common/ayon_common/resources/edit.png b/common/ayon_common/resources/edit.png
deleted file mode 100644
index a5a07998a65..00000000000
Binary files a/common/ayon_common/resources/edit.png and /dev/null differ
diff --git a/common/ayon_common/resources/eye.png b/common/ayon_common/resources/eye.png
deleted file mode 100644
index 5a683e29748..00000000000
Binary files a/common/ayon_common/resources/eye.png and /dev/null differ
diff --git a/common/ayon_common/resources/stylesheet.css b/common/ayon_common/resources/stylesheet.css
deleted file mode 100644
index 01e664e9e8a..00000000000
--- a/common/ayon_common/resources/stylesheet.css
+++ /dev/null
@@ -1,84 +0,0 @@
-* {
- font-size: 10pt;
- font-family: "Noto Sans";
- font-weight: 450;
- outline: none;
-}
-
-QWidget {
- color: #D3D8DE;
- background: #2C313A;
- border-radius: 0px;
-}
-
-QWidget:disabled {
- color: #5b6779;
-}
-
-QLabel {
- background: transparent;
-}
-
-QPushButton {
- text-align:center center;
- border: 0px solid transparent;
- border-radius: 0.2em;
- padding: 3px 5px 3px 5px;
- background: #434a56;
-}
-
-QPushButton:hover {
- background: rgba(168, 175, 189, 0.3);
- color: #F0F2F5;
-}
-
-QPushButton:pressed {}
-
-QPushButton:disabled {
- background: #434a56;
-}
-
-QLineEdit {
- border: 1px solid #373D48;
- border-radius: 0.3em;
- background: #21252B;
- padding: 0.1em;
-}
-
-QLineEdit:disabled {
- background: #2C313A;
-}
-QLineEdit:hover {
- border-color: rgba(168, 175, 189, .3);
-}
-QLineEdit:focus {
- border-color: rgb(92, 173, 214);
-}
-
-QLineEdit[state="invalid"] {
- border-color: #AA5050;
-}
-
-#Separator {
- background: rgba(75, 83, 98, 127);
-}
-
-#PasswordBtn {
- border: none;
- padding: 0.1em;
- background: transparent;
-}
-
-#PasswordBtn:hover {
- background: #434a56;
-}
-
-#LikeDisabledInput {
- background: #2C313A;
-}
-#LikeDisabledInput:hover {
- border-color: #373D48;
-}
-#LikeDisabledInput:focus {
- border-color: #373D48;
-}
diff --git a/common/ayon_common/ui_utils.py b/common/ayon_common/ui_utils.py
deleted file mode 100644
index a3894d0d9cd..00000000000
--- a/common/ayon_common/ui_utils.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import sys
-from qtpy import QtWidgets, QtCore
-
-
-def set_style_property(widget, property_name, property_value):
- """Set widget's property that may affect style.
-
- Style of widget is polished if current property value is different.
- """
-
- cur_value = widget.property(property_name)
- if cur_value == property_value:
- return
- widget.setProperty(property_name, property_value)
- widget.style().polish(widget)
-
-
-def get_qt_app():
- app = QtWidgets.QApplication.instance()
- if app is not None:
- return app
-
- for attr_name in (
- "AA_EnableHighDpiScaling",
- "AA_UseHighDpiPixmaps",
- ):
- attr = getattr(QtCore.Qt, attr_name, None)
- if attr is not None:
- QtWidgets.QApplication.setAttribute(attr)
-
- if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"):
- QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy(
- QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
- )
-
- return QtWidgets.QApplication(sys.argv)
diff --git a/common/ayon_common/utils.py b/common/ayon_common/utils.py
deleted file mode 100644
index c0d0c7c0b10..00000000000
--- a/common/ayon_common/utils.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import os
-import sys
-import appdirs
-
-IS_BUILT_APPLICATION = getattr(sys, "frozen", False)
-
-
-def get_ayon_appdirs(*args):
- """Local app data directory of AYON client.
-
- Args:
- *args (Iterable[str]): Subdirectories/files in local app data dir.
-
- Returns:
- str: Path to directory/file in local app data dir.
- """
-
- return os.path.join(
- appdirs.user_data_dir("AYON", "Ynput"),
- *args
- )
-
-
-def is_staging_enabled():
- """Check if staging is enabled.
-
- Returns:
- bool: True if staging is enabled.
- """
-
- return os.getenv("AYON_USE_STAGING") == "1"
-
-
-def _create_local_site_id():
- """Create a local site identifier.
-
- Returns:
- str: Randomly generated site id.
- """
-
- from coolname import generate_slug
-
- new_id = generate_slug(3)
-
- print("Created local site id \"{}\"".format(new_id))
-
- return new_id
-
-
-def get_local_site_id():
- """Get local site identifier.
-
- Site id is created if does not exist yet.
-
- Returns:
- str: Site id.
- """
-
- # used for background syncing
- site_id = os.environ.get("AYON_SITE_ID")
- if site_id:
- return site_id
-
- site_id_path = get_ayon_appdirs("site_id")
- if os.path.exists(site_id_path):
- with open(site_id_path, "r") as stream:
- site_id = stream.read()
-
- if not site_id:
- site_id = _create_local_site_id()
- with open(site_id_path, "w") as stream:
- stream.write(site_id)
- return site_id
-
-
-def get_ayon_launch_args(*args):
- """Launch arguments that can be used to launch ayon process.
-
- Args:
- *args (str): Additional arguments.
-
- Returns:
- list[str]: Launch arguments.
- """
-
- output = [sys.executable]
- if not IS_BUILT_APPLICATION:
- output.append(sys.argv[0])
- output.extend(args)
- return output
diff --git a/openpype/action.py b/openpype/action.py
deleted file mode 100644
index 6114c65fd44..00000000000
--- a/openpype/action.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import warnings
-import functools
-import pyblish.api
-
-
-class ActionDeprecatedWarning(DeprecationWarning):
- pass
-
-
-def deprecated(new_destination):
- """Mark functions as deprecated.
-
- It will result in a warning being emitted when the function is used.
- """
-
- func = None
- if callable(new_destination):
- func = new_destination
- new_destination = None
-
- def _decorator(decorated_func):
- if new_destination is None:
- warning_message = (
- " Please check content of deprecated function to figure out"
- " possible replacement."
- )
- else:
- warning_message = " Please replace your usage with '{}'.".format(
- new_destination
- )
-
- @functools.wraps(decorated_func)
- def wrapper(*args, **kwargs):
- warnings.simplefilter("always", ActionDeprecatedWarning)
- warnings.warn(
- (
- "Call to deprecated function '{}'"
- "\nFunction was moved or removed.{}"
- ).format(decorated_func.__name__, warning_message),
- category=ActionDeprecatedWarning,
- stacklevel=4
- )
- return decorated_func(*args, **kwargs)
- return wrapper
-
- if func is None:
- return _decorator
- return _decorator(func)
-
-
-@deprecated("openpype.pipeline.publish.get_errored_instances_from_context")
-def get_errored_instances_from_context(context, plugin=None):
- """
- Deprecated:
- Since 3.14.* will be removed in 3.16.* or later.
- """
-
- from openpype.pipeline.publish import get_errored_instances_from_context
-
- return get_errored_instances_from_context(context, plugin=plugin)
-
-
-@deprecated("openpype.pipeline.publish.get_errored_plugins_from_context")
-def get_errored_plugins_from_data(context):
- """
- Deprecated:
- Since 3.14.* will be removed in 3.16.* or later.
- """
-
- from openpype.pipeline.publish import get_errored_plugins_from_context
-
- return get_errored_plugins_from_context(context)
-
-
-class RepairAction(pyblish.api.Action):
- """Repairs the action
-
- To process the repairing this requires a static `repair(instance)` method
- is available on the plugin.
-
- Deprecated:
- 'RepairAction' and 'RepairContextAction' were moved to
- 'openpype.pipeline.publish' please change you imports.
- There is no "reasonable" way hot mark these classes as deprecated
- to show warning of wrong import. Deprecated since 3.14.* will be
- removed in 3.16.*
-
- """
- label = "Repair"
- on = "failed" # This action is only available on a failed plug-in
- icon = "wrench" # Icon from Awesome Icon
-
- def process(self, context, plugin):
-
- if not hasattr(plugin, "repair"):
- raise RuntimeError("Plug-in does not have repair method.")
-
- # Get the errored instances
- self.log.info("Finding failed instances..")
- errored_instances = get_errored_instances_from_context(context,
- plugin=plugin)
- for instance in errored_instances:
- plugin.repair(instance)
-
-
-class RepairContextAction(pyblish.api.Action):
- """Repairs the action
-
- To process the repairing this requires a static `repair(instance)` method
- is available on the plugin.
-
- Deprecated:
- 'RepairAction' and 'RepairContextAction' were moved to
- 'openpype.pipeline.publish' please change you imports.
- There is no "reasonable" way hot mark these classes as deprecated
- to show warning of wrong import. Deprecated since 3.14.* will be
- removed in 3.16.*
-
- """
- label = "Repair"
- on = "failed" # This action is only available on a failed plug-in
-
- def process(self, context, plugin):
-
- if not hasattr(plugin, "repair"):
- raise RuntimeError("Plug-in does not have repair method.")
-
- # Get the errored instances
- self.log.info("Finding failed instances..")
- errored_plugins = get_errored_plugins_from_data(context)
-
- # Apply pyblish.logic to get the instances for the plug-in
- if plugin in errored_plugins:
- self.log.info("Attempting fix ...")
- plugin.repair(context)
diff --git a/openpype/cli.py b/openpype/cli.py
index bc837cdeba8..0df277fb0a1 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -5,6 +5,7 @@
import code
import click
+from openpype import AYON_SERVER_ENABLED
from .pype_commands import PypeCommands
@@ -46,7 +47,11 @@ def main(ctx):
if ctx.invoked_subcommand is None:
# Print help if headless mode is used
- if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1":
+ if AYON_SERVER_ENABLED:
+ is_headless = os.getenv("AYON_HEADLESS_MODE") == "1"
+ else:
+ is_headless = os.getenv("OPENPYPE_HEADLESS_MODE") == "1"
+ if is_headless:
print(ctx.get_help())
sys.exit(0)
else:
@@ -57,6 +62,9 @@ def main(ctx):
@click.option("-d", "--dev", is_flag=True, help="Settings in Dev mode")
def settings(dev):
"""Show Pype Settings UI."""
+
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'settings' command.")
PypeCommands().launch_settings_gui(dev)
@@ -110,6 +118,8 @@ def eventserver(ftrack_url,
on linux and window service).
"""
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'eventserver' command.")
PypeCommands().launch_eventservercli(
ftrack_url,
ftrack_user,
@@ -134,6 +144,10 @@ def webpublisherwebserver(executable, upload_dir, host=None, port=None):
Expect "pype.club" user created on Ftrack.
"""
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError(
+ "AYON does not support 'webpublisherwebserver' command."
+ )
PypeCommands().launch_webpublisher_webservercli(
upload_dir=upload_dir,
executable=executable,
@@ -182,43 +196,10 @@ def publish(paths, targets, gui):
PypeCommands.publish(list(paths), targets, gui)
-@main.command()
-@click.argument("path")
-@click.option("-h", "--host", help="Host")
-@click.option("-u", "--user", help="User email address")
-@click.option("-p", "--project", help="Project")
-@click.option("-t", "--targets", help="Targets", default=None,
- multiple=True)
-def remotepublishfromapp(project, path, host, user=None, targets=None):
- """Start CLI publishing.
-
- Publish collects json from paths provided as an argument.
- More than one path is allowed.
- """
-
- PypeCommands.remotepublishfromapp(
- project, path, host, user, targets=targets
- )
-
-
-@main.command()
-@click.argument("path")
-@click.option("-u", "--user", help="User email address")
-@click.option("-p", "--project", help="Project")
-@click.option("-t", "--targets", help="Targets", default=None,
- multiple=True)
-def remotepublish(project, path, user=None, targets=None):
- """Start CLI publishing.
-
- Publish collects json from paths provided as an argument.
- More than one path is allowed.
- """
-
- PypeCommands.remotepublish(project, path, user, targets=targets)
-
-
@main.command(context_settings={"ignore_unknown_options": True})
def projectmanager():
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'projectmanager' command.")
PypeCommands().launch_project_manager()
@@ -316,12 +297,18 @@ def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant,
persist, app_variant, timeout, setup_only)
-@main.command()
+@main.command(help="DEPRECATED - run sync server")
+@click.pass_context
@click.option("-a", "--active_site", required=True,
- help="Name of active stie")
-def syncserver(active_site):
+ help="Name of active site")
+def syncserver(ctx, active_site):
"""Run sync site server in background.
+ Deprecated:
+ This command is deprecated and will be removed in future versions.
+ Use '~/openpype_console module sync_server syncservice' instead.
+
+ Details:
Some Site Sync use cases need to expose site to another one.
For example if majority of artists work in studio, they are not using
SS at all, but if you want to expose published assets to 'studio' site
@@ -335,7 +322,12 @@ def syncserver(active_site):
var OPENPYPE_LOCAL_ID set to 'active_site'.
"""
- PypeCommands().syncserver(active_site)
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'syncserver' command.")
+
+ from openpype.modules.sync_server.sync_server_module import (
+ syncservice)
+ ctx.invoke(syncservice, active_site=active_site)
@main.command()
@@ -347,6 +339,8 @@ def repack_version(directory):
recalculating file checksums. It will try to use version detected in
directory name.
"""
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'repack-version' command.")
PypeCommands().repack_version(directory)
@@ -358,6 +352,9 @@ def repack_version(directory):
"--dbonly", help="Store only Database data", default=False, is_flag=True)
def pack_project(project, dirpath, dbonly):
"""Create a package of project with all files and database dump."""
+
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'pack-project' command.")
PypeCommands().pack_project(project, dirpath, dbonly)
@@ -370,6 +367,8 @@ def pack_project(project, dirpath, dbonly):
"--dbonly", help="Store only Database data", default=False, is_flag=True)
def unpack_project(zipfile, root, dbonly):
"""Create a package of project with all files and database dump."""
+ if AYON_SERVER_ENABLED:
+ raise RuntimeError("AYON does not support 'unpack-project' command.")
PypeCommands().unpack_project(zipfile, root, dbonly)
@@ -384,9 +383,17 @@ def interactive():
Executable 'openpype_gui' on Windows won't work.
"""
- from openpype.version import __version__
+ if AYON_SERVER_ENABLED:
+ version = os.environ["AYON_VERSION"]
+ banner = (
+ f"AYON launcher {version}\nPython {sys.version} on {sys.platform}"
+ )
+ else:
+ from openpype.version import __version__
- banner = f"OpenPype {__version__}\nPython {sys.version} on {sys.platform}"
+ banner = (
+ f"OpenPype {__version__}\nPython {sys.version} on {sys.platform}"
+ )
code.interact(banner)
@@ -395,11 +402,13 @@ def interactive():
is_flag=True, default=False)
def version(build):
"""Print OpenPype version."""
+ if AYON_SERVER_ENABLED:
+ print(os.environ["AYON_VERSION"])
+ return
from openpype.version import __version__
from igniter.bootstrap_repos import BootstrapRepos, OpenPypeVersion
from pathlib import Path
- import os
if getattr(sys, 'frozen', False):
local_version = BootstrapRepos.get_version(
diff --git a/openpype/client/mongo/__init__.py b/openpype/client/mongo/__init__.py
index 5c5143a7310..9f62d7a9cfa 100644
--- a/openpype/client/mongo/__init__.py
+++ b/openpype/client/mongo/__init__.py
@@ -6,6 +6,9 @@
OpenPypeMongoConnection,
get_project_database,
get_project_connection,
+ load_json_file,
+ replace_project_documents,
+ store_project_documents,
)
@@ -17,4 +20,7 @@
"OpenPypeMongoConnection",
"get_project_database",
"get_project_connection",
+ "load_json_file",
+ "replace_project_documents",
+ "store_project_documents",
)
diff --git a/openpype/client/mongo/entity_links.py b/openpype/client/mongo/entity_links.py
index c97a828118b..fd13a2d83be 100644
--- a/openpype/client/mongo/entity_links.py
+++ b/openpype/client/mongo/entity_links.py
@@ -212,16 +212,12 @@ def _process_referenced_pipeline_result(result, link_type):
continue
for output in sorted(outputs_recursive, key=lambda o: o["depth"]):
- output_links = output.get("data", {}).get("inputLinks")
- if not output_links and output["type"] != "hero_version":
- continue
-
# Leaf
if output["_id"] not in correctly_linked_ids:
continue
_filter_input_links(
- output_links,
+ output.get("data", {}).get("inputLinks"),
link_type,
correctly_linked_ids
)
diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py
index 24d46780957..a6c190a0fc9 100644
--- a/openpype/client/server/conversion_utils.py
+++ b/openpype/client/server/conversion_utils.py
@@ -133,7 +133,6 @@ def _get_default_template_name(templates):
def _template_replacements_to_v3(template):
return (
template
- .replace("{folder[name]}", "{asset}")
.replace("{product[name]}", "{subset}")
.replace("{product[type]}", "{family}")
)
@@ -715,7 +714,6 @@ def convert_v4_representation_to_v3(representation):
if "template" in output_data:
output_data["template"] = (
output_data["template"]
- .replace("{folder[name]}", "{asset}")
.replace("{product[name]}", "{subset}")
.replace("{product[type]}", "{family}")
)
@@ -977,7 +975,6 @@ def convert_create_representation_to_v4(representation, con):
representation_data = representation["data"]
representation_data["template"] = (
representation_data["template"]
- .replace("{asset}", "{folder[name]}")
.replace("{subset}", "{product[name]}")
.replace("{family}", "{product[type]}")
)
@@ -1077,7 +1074,7 @@ def convert_update_folder_to_v4(project_name, asset_id, update_data, con):
parent_id = None
tasks = None
new_data = {}
- attribs = {}
+ attribs = full_update_data.pop("attrib", {})
if "type" in update_data:
new_update_data["active"] = update_data["type"] == "asset"
@@ -1116,6 +1113,9 @@ def convert_update_folder_to_v4(project_name, asset_id, update_data, con):
print("Folder has new data: {}".format(new_data))
new_update_data["data"] = new_data
+ if attribs:
+ new_update_data["attrib"] = attribs
+
if has_task_changes:
raise ValueError("Task changes of folder are not implemented")
@@ -1129,7 +1129,7 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con):
full_update_data = _from_flat_dict(update_data)
data = full_update_data.get("data")
new_data = {}
- attribs = {}
+ attribs = full_update_data.pop("attrib", {})
if data:
if "family" in data:
family = data.pop("family")
@@ -1151,9 +1151,6 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con):
elif value is not REMOVED_VALUE:
new_data[key] = value
- if attribs:
- new_update_data["attribs"] = attribs
-
if "name" in update_data:
new_update_data["name"] = update_data["name"]
@@ -1168,6 +1165,9 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con):
new_update_data["folderId"] = update_data["parent"]
flat_data = _to_flat_dict(new_update_data)
+ if attribs:
+ flat_data["attrib"] = attribs
+
if new_data:
print("Subset has new data: {}".format(new_data))
flat_data["data"] = new_data
@@ -1182,7 +1182,7 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con):
full_update_data = _from_flat_dict(update_data)
data = full_update_data.get("data")
new_data = {}
- attribs = {}
+ attribs = full_update_data.pop("attrib", {})
if data:
if "author" in data:
new_update_data["author"] = data.pop("author")
@@ -1199,9 +1199,6 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con):
elif value is not REMOVED_VALUE:
new_data[key] = value
- if attribs:
- new_update_data["attribs"] = attribs
-
if "name" in update_data:
new_update_data["version"] = update_data["name"]
@@ -1216,6 +1213,9 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con):
new_update_data["productId"] = update_data["parent"]
flat_data = _to_flat_dict(new_update_data)
+ if attribs:
+ flat_data["attrib"] = attribs
+
if new_data:
print("Version has new data: {}".format(new_data))
flat_data["data"] = new_data
@@ -1255,7 +1255,7 @@ def convert_update_representation_to_v4(
data = full_update_data.get("data")
new_data = {}
- attribs = {}
+ attribs = full_update_data.pop("attrib", {})
if data:
for key, value in data.items():
if key in folder_attributes:
@@ -1266,7 +1266,6 @@ def convert_update_representation_to_v4(
if "template" in attribs:
attribs["template"] = (
attribs["template"]
- .replace("{asset}", "{folder[name]}")
.replace("{family}", "{product[type]}")
.replace("{subset}", "{product[name]}")
)
@@ -1313,6 +1312,9 @@ def convert_update_representation_to_v4(
new_update_data["files"] = new_files
flat_data = _to_flat_dict(new_update_data)
+ if attribs:
+ flat_data["attrib"] = attribs
+
if new_data:
print("Representation has new data: {}".format(new_data))
flat_data["data"] = new_data
diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py
index 9579f13add1..39322627bb5 100644
--- a/openpype/client/server/entities.py
+++ b/openpype/client/server/entities.py
@@ -83,10 +83,10 @@ def _get_subsets(
project_name,
subset_ids,
subset_names,
- folder_ids,
- names_by_folder_ids,
- active,
- fields
+ folder_ids=folder_ids,
+ names_by_folder_ids=names_by_folder_ids,
+ active=active,
+ fields=fields,
):
yield convert_v4_subset_to_v3(subset)
diff --git a/openpype/vendor/python/common/ayon_api/thumbnails.py b/openpype/client/server/thumbnails.py
similarity index 93%
rename from openpype/vendor/python/common/ayon_api/thumbnails.py
rename to openpype/client/server/thumbnails.py
index 11734ca7624..dc649b96515 100644
--- a/openpype/vendor/python/common/ayon_api/thumbnails.py
+++ b/openpype/client/server/thumbnails.py
@@ -1,3 +1,11 @@
+"""Cache of thumbnails downloaded from AYON server.
+
+Thumbnails are cached to appdirs to predefined directory.
+
+This should be moved to thumbnails logic in pipeline but because it would
+overflow OpenPype logic it's here for now.
+"""
+
import os
import time
import collections
@@ -10,7 +18,7 @@
)
-class ThumbnailCache:
+class AYONThumbnailCache:
"""Cache of thumbnails on local storage.
Thumbnails are cached to appdirs to predefined directory. Each project has
@@ -32,13 +40,14 @@ class ThumbnailCache:
# Lifetime of thumbnails (in seconds)
# - default 3 days
- days_alive = 3 * 24 * 60 * 60
+ days_alive = 3
# Max size of thumbnail directory (in bytes)
# - default 2 Gb
max_filesize = 2 * 1024 * 1024 * 1024
def __init__(self, cleanup=True):
self._thumbnails_dir = None
+ self._days_alive_secs = self.days_alive * 24 * 60 * 60
if cleanup:
self.cleanup()
@@ -50,6 +59,7 @@ def get_thumbnails_dir(self):
"""
if self._thumbnails_dir is None:
+ # TODO use generic function
directory = appdirs.user_data_dir("AYON", "Ynput")
self._thumbnails_dir = os.path.join(directory, "thumbnails")
return self._thumbnails_dir
@@ -121,7 +131,7 @@ def _soft_cleanup(self, thumbnails_dir):
for filename in filenames:
path = os.path.join(root, filename)
modification_time = os.path.getmtime(path)
- if current_time - modification_time > self.days_alive:
+ if current_time - modification_time > self._days_alive_secs:
os.remove(path)
def _max_size_cleanup(self, thumbnails_dir):
diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py
index c54acbc2039..1418bc210b1 100644
--- a/openpype/hooks/pre_add_last_workfile_arg.py
+++ b/openpype/hooks/pre_add_last_workfile_arg.py
@@ -1,6 +1,6 @@
import os
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class AddLastWorkfileToLaunchArgs(PreLaunchHook):
@@ -13,8 +13,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
# Execute after workfile template copy
order = 10
- app_groups = [
- "3dsmax",
+ app_groups = {
+ "3dsmax", "adsk_3dsmax",
"maya",
"nuke",
"nukex",
@@ -26,8 +26,9 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"photoshop",
"tvpaint",
"substancepainter",
- "aftereffects"
- ]
+ "aftereffects",
+ }
+ launch_types = {LaunchTypes.local}
def execute(self):
if not self.data.get("start_last_workfile"):
diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py
index 70c549919fa..2203ff43963 100644
--- a/openpype/hooks/pre_copy_template_workfile.py
+++ b/openpype/hooks/pre_copy_template_workfile.py
@@ -1,7 +1,7 @@
import os
import shutil
-from openpype.lib import PreLaunchHook
from openpype.settings import get_project_settings
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
from openpype.pipeline.workfile import (
get_custom_workfile_template,
get_custom_workfile_template_by_string_context
@@ -19,7 +19,8 @@ class CopyTemplateWorkfile(PreLaunchHook):
# Before `AddLastWorkfileToLaunchArgs`
order = 0
- app_groups = ["blender", "photoshop", "tvpaint", "aftereffects"]
+ app_groups = {"blender", "photoshop", "tvpaint", "aftereffects"}
+ launch_types = {LaunchTypes.local}
def execute(self):
"""Check if can copy template for context and do it if possible.
diff --git a/openpype/hooks/pre_create_extra_workdir_folders.py b/openpype/hooks/pre_create_extra_workdir_folders.py
index 8856281120f..4c9d08b3755 100644
--- a/openpype/hooks/pre_create_extra_workdir_folders.py
+++ b/openpype/hooks/pre_create_extra_workdir_folders.py
@@ -1,5 +1,5 @@
import os
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
from openpype.pipeline.workfile import create_workdir_extra_folders
@@ -14,6 +14,7 @@ class CreateWorkdirExtraFolders(PreLaunchHook):
# Execute after workfile template copy
order = 15
+ launch_types = {LaunchTypes.local}
def execute(self):
if not self.application.is_host:
diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py
index 21ec8e78814..7536df4c16d 100644
--- a/openpype/hooks/pre_foundry_apps.py
+++ b/openpype/hooks/pre_foundry_apps.py
@@ -1,5 +1,5 @@
import subprocess
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class LaunchFoundryAppsWindows(PreLaunchHook):
@@ -13,8 +13,9 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
# Should be as last hook because must change launch arguments to string
order = 1000
- app_groups = ["nuke", "nukeassist", "nukex", "hiero", "nukestudio"]
- platforms = ["windows"]
+ app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"}
+ platforms = {"windows"}
+ launch_types = {LaunchTypes.local}
def execute(self):
# Change `creationflags` to CREATE_NEW_CONSOLE
diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py
index 260e28a18b9..813df24af01 100644
--- a/openpype/hooks/pre_global_host_data.py
+++ b/openpype/hooks/pre_global_host_data.py
@@ -1,5 +1,5 @@
from openpype.client import get_project, get_asset_by_name
-from openpype.lib import (
+from openpype.lib.applications import (
PreLaunchHook,
EnvironmentPrepData,
prepare_app_environments,
@@ -10,6 +10,7 @@
class GlobalHostDataHook(PreLaunchHook):
order = -100
+ launch_types = set()
def execute(self):
"""Prepare global objects to `data` that will be used for sure."""
diff --git a/openpype/hooks/pre_mac_launch.py b/openpype/hooks/pre_mac_launch.py
index f85557a4f00..402e9a55172 100644
--- a/openpype/hooks/pre_mac_launch.py
+++ b/openpype/hooks/pre_mac_launch.py
@@ -1,5 +1,5 @@
import os
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class LaunchWithTerminal(PreLaunchHook):
@@ -12,7 +12,8 @@ class LaunchWithTerminal(PreLaunchHook):
"""
order = 1000
- platforms = ["darwin"]
+ platforms = {"darwin"}
+ launch_types = {LaunchTypes.local}
def execute(self):
executable = str(self.launch_context.executable)
diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py
index 043cb3c7f69..d9e912c8269 100644
--- a/openpype/hooks/pre_non_python_host_launch.py
+++ b/openpype/hooks/pre_non_python_host_launch.py
@@ -1,10 +1,11 @@
import os
-from openpype.lib import (
+from openpype.lib import get_openpype_execute_args
+from openpype.lib.applications import (
+ get_non_python_host_kwargs,
PreLaunchHook,
- get_openpype_execute_args
+ LaunchTypes,
)
-from openpype.lib.applications import get_non_python_host_kwargs
from openpype import PACKAGE_DIR as OPENPYPE_DIR
@@ -16,9 +17,10 @@ class NonPythonHostHook(PreLaunchHook):
python script which launch the host. For these cases it is necessary to
prepend python (or openpype) executable and script path before application's.
"""
- app_groups = ["harmony", "photoshop", "aftereffects"]
+ app_groups = {"harmony", "photoshop", "aftereffects"}
order = 20
+ launch_types = {LaunchTypes.local}
def execute(self):
# Pop executable
@@ -54,4 +56,3 @@ def execute(self):
self.launch_context.kwargs = \
get_non_python_host_kwargs(self.launch_context.kwargs)
-
diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py
index 8f462665bcf..add3a0adaf1 100644
--- a/openpype/hooks/pre_ocio_hook.py
+++ b/openpype/hooks/pre_ocio_hook.py
@@ -1,8 +1,6 @@
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook
-from openpype.pipeline.colorspace import (
- get_imageio_config
-)
+from openpype.pipeline.colorspace import get_imageio_config
from openpype.pipeline.template_data import get_template_data_with_names
@@ -10,7 +8,7 @@ class OCIOEnvHook(PreLaunchHook):
"""Set OCIO environment variable for hosts that use OpenColorIO."""
order = 0
- hosts = [
+ hosts = {
"substancepainter",
"fusion",
"blender",
@@ -20,8 +18,9 @@ class OCIOEnvHook(PreLaunchHook):
"maya",
"nuke",
"hiero",
- "resolve"
- ]
+ "resolve",
+ }
+ launch_types = set()
def execute(self):
"""Hook entry method."""
@@ -39,12 +38,16 @@ def execute(self):
host_name=self.host_name,
project_settings=self.data["project_settings"],
anatomy_data=template_data,
- anatomy=self.data["anatomy"]
+ anatomy=self.data["anatomy"],
+ env=self.launch_context.env,
)
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/host/dirmap.py b/openpype/host/dirmap.py
index e77f06e9d6c..96a98e808e7 100644
--- a/openpype/host/dirmap.py
+++ b/openpype/host/dirmap.py
@@ -32,19 +32,26 @@ class HostDirmap(object):
"""
def __init__(
- self, host_name, project_name, project_settings=None, sync_module=None
+ self,
+ host_name,
+ project_name,
+ project_settings=None,
+ sync_module=None
):
self.host_name = host_name
self.project_name = project_name
self._project_settings = project_settings
- self._sync_module = sync_module # to limit reinit of Modules
+ self._sync_module = sync_module
+ # to limit reinit of Modules
+ self._sync_module_discovered = sync_module is not None
self._log = None
@property
def sync_module(self):
- if self._sync_module is None:
+ if not self._sync_module_discovered:
+ self._sync_module_discovered = True
manager = ModulesManager()
- self._sync_module = manager["sync_server"]
+ self._sync_module = manager.get("sync_server")
return self._sync_module
@property
@@ -151,21 +158,25 @@ def _get_local_sync_dirmap(self):
"""
project_name = self.project_name
+ sync_module = self.sync_module
mapping = {}
- if (not self.sync_module.enabled or
- project_name not in self.sync_module.get_enabled_projects()):
+ if (
+ sync_module is None
+ or not sync_module.enabled
+ or project_name not in sync_module.get_enabled_projects()
+ ):
return mapping
- active_site = self.sync_module.get_local_normalized_site(
- self.sync_module.get_active_site(project_name))
- remote_site = self.sync_module.get_local_normalized_site(
- self.sync_module.get_remote_site(project_name))
+ active_site = sync_module.get_local_normalized_site(
+ sync_module.get_active_site(project_name))
+ remote_site = sync_module.get_local_normalized_site(
+ sync_module.get_remote_site(project_name))
self.log.debug(
"active {} - remote {}".format(active_site, remote_site)
)
if active_site == "local" and active_site != remote_site:
- sync_settings = self.sync_module.get_sync_project_setting(
+ sync_settings = sync_module.get_sync_project_setting(
project_name,
exclude_locals=False,
cached=False)
@@ -179,7 +190,7 @@ def _get_local_sync_dirmap(self):
self.log.debug("remote overrides {}".format(remote_overrides))
current_platform = platform.system().lower()
- remote_provider = self.sync_module.get_provider_for_site(
+ remote_provider = sync_module.get_provider_for_site(
project_name, remote_site
)
# dirmap has sense only with regular disk provider, in the workfile
diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py
index fa79fac78f8..dcf424b44f4 100644
--- a/openpype/hosts/aftereffects/plugins/create/create_render.py
+++ b/openpype/hosts/aftereffects/plugins/create/create_render.py
@@ -28,7 +28,6 @@ class RenderCreator(Creator):
create_allow_context_change = True
# Settings
- default_variants = []
mark_for_review = True
def create(self, subset_name_from_ui, data, pre_create_data):
@@ -171,6 +170,10 @@ def apply_settings(self, project_settings, system_settings):
)
self.mark_for_review = plugin_settings["mark_for_review"]
+ self.default_variants = plugin_settings.get(
+ "default_variants",
+ plugin_settings.get("defaults") or []
+ )
def get_detail_description(self):
return """Creator for Render instances
diff --git a/openpype/hosts/aftereffects/plugins/publish/closeAE.py b/openpype/hosts/aftereffects/plugins/publish/closeAE.py
index eff2573e8fb..0be20d9f05a 100644
--- a/openpype/hosts/aftereffects/plugins/publish/closeAE.py
+++ b/openpype/hosts/aftereffects/plugins/publish/closeAE.py
@@ -15,7 +15,7 @@ class CloseAE(pyblish.api.ContextPlugin):
active = True
hosts = ["aftereffects"]
- targets = ["remotepublish"]
+ targets = ["automated"]
def process(self, context):
self.log.info("CloseAE")
diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py
index aa464619157..49874d6cff5 100644
--- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py
+++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py
@@ -138,7 +138,6 @@ def get_instances(self, context):
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/aftereffects/plugins/publish/extract_local_render.py b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
index c70aa41dbe0..bdb48e11f8b 100644
--- a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
+++ b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
@@ -1,11 +1,5 @@
import os
-import sys
-import six
-from openpype.lib import (
- get_ffmpeg_tool_path,
- run_subprocess,
-)
from openpype.pipeline import publish
from openpype.hosts.aftereffects.api import get_stub
diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py
index 2c1b7245cd6..62d7987b47d 100644
--- a/openpype/hosts/blender/api/ops.py
+++ b/openpype/hosts/blender/api/ops.py
@@ -20,6 +20,7 @@
from openpype.tools.utils import host_tools
from .workio import OpenFileCacher
+from . import pipeline
PREVIEW_COLLECTIONS: Dict = dict()
@@ -344,6 +345,26 @@ def before_window_show(self):
self._window.refresh()
+class SetFrameRange(bpy.types.Operator):
+ bl_idname = "wm.ayon_set_frame_range"
+ bl_label = "Set Frame Range"
+
+ def execute(self, context):
+ data = pipeline.get_asset_data()
+ pipeline.set_frame_range(data)
+ return {"FINISHED"}
+
+
+class SetResolution(bpy.types.Operator):
+ bl_idname = "wm.ayon_set_resolution"
+ bl_label = "Set Resolution"
+
+ def execute(self, context):
+ data = pipeline.get_asset_data()
+ pipeline.set_resolution(data)
+ return {"FINISHED"}
+
+
class TOPBAR_MT_avalon(bpy.types.Menu):
"""Avalon menu."""
@@ -381,9 +402,11 @@ def draw(self, context):
layout.operator(LaunchManager.bl_idname, text="Manage...")
layout.operator(LaunchLibrary.bl_idname, text="Library...")
layout.separator()
+ layout.operator(SetFrameRange.bl_idname, text="Set Frame Range")
+ layout.operator(SetResolution.bl_idname, text="Set Resolution")
+ layout.separator()
layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...")
- # TODO (jasper): maybe add 'Reload Pipeline', 'Set Frame Range' and
- # 'Set Resolution'?
+ # TODO (jasper): maybe add 'Reload Pipeline'
def draw_avalon_menu(self, context):
@@ -399,6 +422,8 @@ def draw_avalon_menu(self, context):
LaunchManager,
LaunchLibrary,
LaunchWorkFiles,
+ SetFrameRange,
+ SetResolution,
TOPBAR_MT_avalon,
]
diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py
index eb696ec1849..29339a512c1 100644
--- a/openpype/hosts/blender/api/pipeline.py
+++ b/openpype/hosts/blender/api/pipeline.py
@@ -113,22 +113,21 @@ def message_window(title, message):
_process_app_events()
-def set_start_end_frames():
+def get_asset_data():
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.get("data")
+
+
+def set_frame_range(data):
scene = bpy.context.scene
# Default scene settings
frameStart = scene.frame_start
frameEnd = scene.frame_end
fps = scene.render.fps / scene.render.fps_base
- resolution_x = scene.render.resolution_x
- resolution_y = scene.render.resolution_y
-
- # Check if settings are set
- data = asset_doc.get("data")
if not data:
return
@@ -139,26 +138,47 @@ def set_start_end_frames():
frameEnd = data.get("frameEnd")
if data.get("fps"):
fps = data.get("fps")
- if data.get("resolutionWidth"):
- resolution_x = data.get("resolutionWidth")
- if data.get("resolutionHeight"):
- resolution_y = data.get("resolutionHeight")
scene.frame_start = frameStart
scene.frame_end = frameEnd
scene.render.fps = round(fps)
scene.render.fps_base = round(fps) / fps
+
+
+def set_resolution(data):
+ scene = bpy.context.scene
+
+ # Default scene settings
+ resolution_x = scene.render.resolution_x
+ resolution_y = scene.render.resolution_y
+
+ if not data:
+ return
+
+ if data.get("resolutionWidth"):
+ resolution_x = data.get("resolutionWidth")
+ if data.get("resolutionHeight"):
+ resolution_y = data.get("resolutionHeight")
+
scene.render.resolution_x = resolution_x
scene.render.resolution_y = resolution_y
def on_new():
- set_start_end_frames()
-
project = os.environ.get("AVALON_PROJECT")
- settings = get_project_settings(project)
+ settings = get_project_settings(project).get("blender")
+
+ set_resolution_startup = settings.get("set_resolution_startup")
+ set_frames_startup = settings.get("set_frames_startup")
+
+ data = get_asset_data()
+
+ if set_resolution_startup:
+ set_resolution(data)
+ if set_frames_startup:
+ set_frame_range(data)
- unit_scale_settings = settings.get("blender").get("unit_scale_settings")
+ unit_scale_settings = settings.get("unit_scale_settings")
unit_scale_enabled = unit_scale_settings.get("enabled")
if unit_scale_enabled:
unit_scale = unit_scale_settings.get("base_file_unit_scale")
@@ -166,12 +186,20 @@ def on_new():
def on_open():
- set_start_end_frames()
-
project = os.environ.get("AVALON_PROJECT")
- settings = get_project_settings(project)
+ settings = get_project_settings(project).get("blender")
+
+ set_resolution_startup = settings.get("set_resolution_startup")
+ set_frames_startup = settings.get("set_frames_startup")
+
+ data = get_asset_data()
+
+ if set_resolution_startup:
+ set_resolution(data)
+ if set_frames_startup:
+ set_frame_range(data)
- unit_scale_settings = settings.get("blender").get("unit_scale_settings")
+ unit_scale_settings = settings.get("unit_scale_settings")
unit_scale_enabled = unit_scale_settings.get("enabled")
apply_on_opening = unit_scale_settings.get("apply_on_opening")
if unit_scale_enabled and apply_on_opening:
diff --git a/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py b/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py
index 559e9ae0ce1..68c9bfdd575 100644
--- a/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py
+++ b/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py
@@ -1,6 +1,6 @@
from pathlib import Path
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class AddPythonScriptToLaunchArgs(PreLaunchHook):
@@ -8,9 +8,8 @@ class AddPythonScriptToLaunchArgs(PreLaunchHook):
# Append after file argument
order = 15
- app_groups = [
- "blender",
- ]
+ app_groups = {"blender"}
+ launch_types = {LaunchTypes.local}
def execute(self):
if not self.launch_context.data.get("python_scripts"):
diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py
index e5f66d2a26e..777e383215a 100644
--- a/openpype/hosts/blender/hooks/pre_pyside_install.py
+++ b/openpype/hosts/blender/hooks/pre_pyside_install.py
@@ -2,7 +2,7 @@
import re
import subprocess
from platform import system
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class InstallPySideToBlender(PreLaunchHook):
@@ -16,7 +16,8 @@ class InstallPySideToBlender(PreLaunchHook):
blender's python packages.
"""
- app_groups = ["blender"]
+ app_groups = {"blender"}
+ launch_types = {LaunchTypes.local}
def execute(self):
# Prelaunch hook is not crucial
diff --git a/openpype/hosts/blender/hooks/pre_windows_console.py b/openpype/hosts/blender/hooks/pre_windows_console.py
index d6be45b225c..2161b7a2f53 100644
--- a/openpype/hosts/blender/hooks/pre_windows_console.py
+++ b/openpype/hosts/blender/hooks/pre_windows_console.py
@@ -1,5 +1,5 @@
import subprocess
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class BlenderConsoleWindows(PreLaunchHook):
@@ -13,8 +13,9 @@ class BlenderConsoleWindows(PreLaunchHook):
# Should be as last hook because must change launch arguments to string
order = 1000
- app_groups = ["blender"]
- platforms = ["windows"]
+ app_groups = {"blender"}
+ platforms = {"windows"}
+ launch_types = {LaunchTypes.local}
def execute(self):
# Change `creationflags` to CREATE_NEW_CONSOLE
diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py
index 82b3ca11ebd..64599270158 100644
--- a/openpype/hosts/blender/plugins/publish/collect_review.py
+++ b/openpype/hosts/blender/plugins/publish/collect_review.py
@@ -29,6 +29,8 @@ def process(self, instance):
camera = cameras[0].name
self.log.debug(f"camera: {camera}")
+ focal_length = cameras[0].data.lens
+
# get isolate objects list from meshes instance members .
isolate_objects = [
obj
@@ -40,6 +42,10 @@ def process(self, instance):
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,
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py
index 1cab9d225b7..f4babc94d3d 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc.py
@@ -22,8 +22,6 @@ def process(self, instance):
filepath = os.path.join(stagingdir, filename)
context = bpy.context
- scene = context.scene
- view_layer = context.view_layer
# Perform extraction
self.log.info("Performing extraction..")
@@ -31,24 +29,25 @@ def process(self, instance):
plugin.deselect_all()
selected = []
- asset_group = None
+ active = None
for obj in instance:
obj.select_set(True)
selected.append(obj)
+ # Set as active the asset group
if obj.get(AVALON_PROPERTY):
- asset_group = obj
+ active = obj
context = plugin.create_blender_context(
- active=asset_group, selected=selected)
-
- # We export the abc
- bpy.ops.wm.alembic_export(
- context,
- filepath=filepath,
- selected=True,
- flatten=False
- )
+ active=active, selected=selected)
+
+ with bpy.context.temp_override(**context):
+ # We export the abc
+ bpy.ops.wm.alembic_export(
+ filepath=filepath,
+ selected=True,
+ flatten=False
+ )
plugin.deselect_all()
diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
new file mode 100644
index 00000000000..a21a59b151e
--- /dev/null
+++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
@@ -0,0 +1,73 @@
+import os
+
+import bpy
+
+from openpype.pipeline import publish
+from openpype.hosts.blender.api import plugin
+from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
+
+
+class ExtractCameraABC(publish.Extractor):
+ """Extract camera as ABC."""
+
+ label = "Extract Camera (ABC)"
+ hosts = ["blender"]
+ families = ["camera"]
+ optional = True
+
+ def process(self, instance):
+ # Define extract output file path
+ stagingdir = self.staging_dir(instance)
+ 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):
+ asset_group = obj
+ break
+ assert asset_group, "No asset group found"
+
+ # Need to cast to list because children is a tuple
+ selected = list(asset_group.children)
+ active = selected[0]
+
+ for obj in selected:
+ obj.select_set(True)
+
+ context = plugin.create_blender_context(
+ active=active, selected=selected)
+
+ with bpy.context.temp_override(**context):
+ # We export the abc
+ bpy.ops.wm.alembic_export(
+ filepath=filepath,
+ selected=True,
+ flatten=True
+ )
+
+ plugin.deselect_all()
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'abc',
+ 'ext': 'abc',
+ 'files': filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(representation)
+
+ self.log.info("Extracted instance '%s' to: %s",
+ instance.name, representation)
diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
similarity index 98%
rename from openpype/hosts/blender/plugins/publish/extract_camera.py
rename to openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
index 9fd181825cd..315994140e9 100644
--- a/openpype/hosts/blender/plugins/publish/extract_camera.py
+++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
@@ -9,7 +9,7 @@
class ExtractCamera(publish.Extractor):
"""Extract as the camera as FBX."""
- label = "Extract Camera"
+ label = "Extract Camera (FBX)"
hosts = ["blender"]
families = ["camera"]
optional = True
diff --git a/openpype/hosts/celaction/hooks/__init__.py b/openpype/hosts/celaction/hooks/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/openpype/hosts/celaction/hooks/pre_celaction_setup.py b/openpype/hosts/celaction/hooks/pre_celaction_setup.py
index 96e784875c2..83aeab7c58f 100644
--- a/openpype/hosts/celaction/hooks/pre_celaction_setup.py
+++ b/openpype/hosts/celaction/hooks/pre_celaction_setup.py
@@ -2,20 +2,18 @@
import shutil
import winreg
import subprocess
-from openpype.lib import PreLaunchHook, get_openpype_execute_args
-from openpype.hosts.celaction import scripts
-
-CELACTION_SCRIPTS_DIR = os.path.dirname(
- os.path.abspath(scripts.__file__)
-)
+from openpype.lib import get_openpype_execute_args
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
+from openpype.hosts.celaction import CELACTION_ROOT_DIR
class CelactionPrelaunchHook(PreLaunchHook):
"""
Bootstrap celacion with pype
"""
- app_groups = ["celaction"]
- platforms = ["windows"]
+ app_groups = {"celaction"}
+ platforms = {"windows"}
+ launch_types = {LaunchTypes.local}
def execute(self):
asset_doc = self.data["asset_doc"]
@@ -37,7 +35,9 @@ def execute(self):
winreg.KEY_ALL_ACCESS
)
- path_to_cli = os.path.join(CELACTION_SCRIPTS_DIR, "publish_cli.py")
+ path_to_cli = os.path.join(
+ CELACTION_ROOT_DIR, "scripts", "publish_cli.py"
+ )
subprocess_args = get_openpype_execute_args("run", path_to_cli)
openpype_executable = subprocess_args.pop(0)
workfile_settings = self.get_workfile_settings()
@@ -122,9 +122,8 @@ def workfile_path(self):
if not os.path.exists(workfile_path):
# TODO add ability to set different template workfile path via
# settings
- openpype_celaction_dir = os.path.dirname(CELACTION_SCRIPTS_DIR)
template_path = os.path.join(
- openpype_celaction_dir,
+ CELACTION_ROOT_DIR,
"resources",
"celaction_template_scene.scn"
)
diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py
index 83110bb6b55..850569cfdd1 100644
--- a/openpype/hosts/flame/hooks/pre_flame_setup.py
+++ b/openpype/hosts/flame/hooks/pre_flame_setup.py
@@ -6,13 +6,10 @@
from pprint import pformat
from openpype.lib import (
- PreLaunchHook,
get_openpype_username,
run_subprocess,
)
-from openpype.lib.applications import (
- ApplicationLaunchFailed
-)
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
from openpype.hosts import flame as opflame
@@ -22,11 +19,12 @@ class FlamePrelaunch(PreLaunchHook):
Will make sure flame_script_dirs are copied to user's folder defined
in environment var FLAME_SCRIPT_DIR.
"""
- app_groups = ["flame"]
+ app_groups = {"flame"}
permissions = 0o777
wtc_script_path = os.path.join(
opflame.HOST_DIR, "api", "scripts", "wiretap_com.py")
+ launch_types = {LaunchTypes.local}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/openpype/hosts/fusion/api/action.py b/openpype/hosts/fusion/api/action.py
index 347d552108b..66b787c2f13 100644
--- a/openpype/hosts/fusion/api/action.py
+++ b/openpype/hosts/fusion/api/action.py
@@ -18,8 +18,10 @@ class SelectInvalidAction(pyblish.api.Action):
icon = "search" # Icon from Awesome Icon
def process(self, context, plugin):
- errored_instances = get_errored_instances_from_context(context,
- plugin=plugin)
+ errored_instances = get_errored_instances_from_context(
+ context,
+ plugin=plugin,
+ )
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
@@ -51,6 +53,7 @@ def process(self, context, plugin):
names = set()
for tool in invalid:
flow.Select(tool, True)
+ comp.SetActiveTool(tool)
names.add(tool.Name)
self.log.info(
"Selecting invalid tools: %s" % ", ".join(sorted(names))
diff --git a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py
index fd726ccda14..66b0f803aa4 100644
--- a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py
+++ b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py
@@ -2,12 +2,16 @@
import shutil
import platform
from pathlib import Path
-from openpype.lib import PreLaunchHook, ApplicationLaunchFailed
from openpype.hosts.fusion import (
FUSION_HOST_DIR,
FUSION_VERSIONS_DICT,
get_fusion_version,
)
+from openpype.lib.applications import (
+ PreLaunchHook,
+ LaunchTypes,
+ ApplicationLaunchFailed,
+)
class FusionCopyPrefsPrelaunch(PreLaunchHook):
@@ -21,8 +25,9 @@ class FusionCopyPrefsPrelaunch(PreLaunchHook):
Master.prefs is defined in openpype/hosts/fusion/deploy/fusion_shared.prefs
"""
- app_groups = ["fusion"]
+ app_groups = {"fusion"}
order = 2
+ launch_types = {LaunchTypes.local}
def get_fusion_profile_name(self, profile_version) -> str:
# Returns 'Default', unless FUSION16_PROFILE is set
diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py
index f27cd1674ba..576628e8765 100644
--- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py
+++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py
@@ -1,5 +1,9 @@
import os
-from openpype.lib import PreLaunchHook, ApplicationLaunchFailed
+from openpype.lib.applications import (
+ PreLaunchHook,
+ LaunchTypes,
+ ApplicationLaunchFailed,
+)
from openpype.hosts.fusion import (
FUSION_HOST_DIR,
FUSION_VERSIONS_DICT,
@@ -17,8 +21,9 @@ class FusionPrelaunch(PreLaunchHook):
Fusion 18 : Python 3.6 - 3.10
"""
- app_groups = ["fusion"]
+ app_groups = {"fusion"}
order = 1
+ launch_types = {LaunchTypes.local}
def execute(self):
# making sure python 3 is installed at provided path
diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py
index 9e48cc000e2..117347a4c2a 100644
--- a/openpype/hosts/fusion/plugins/publish/collect_render.py
+++ b/openpype/hosts/fusion/plugins/publish/collect_render.py
@@ -108,7 +108,6 @@ def get_instances(self, context):
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 5e9b9094a74..af825c052ac 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 @@ def get_instances(self, context):
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"],
@@ -174,7 +174,6 @@ def get_instances(self, context):
outputFormat=info[1],
outputStartFrame=info[3],
leadingZeros=info[2],
- toBeRenderedOn='deadline',
ignoreFrameHandleCheck=True
)
diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py
index 38b09902c15..5825d95a4a8 100644
--- a/openpype/hosts/harmony/plugins/publish/extract_render.py
+++ b/openpype/hosts/harmony/plugins/publish/extract_render.py
@@ -94,15 +94,14 @@ def process(self, instance):
# Generate thumbnail.
thumbnail_path = os.path.join(path, "thumbnail.png")
- ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg")
- args = [
- ffmpeg_path,
+ args = openpype.lib.get_ffmpeg_tool_args(
+ "ffmpeg",
"-y",
"-i", os.path.join(path, list(collections[0])[0]),
"-vf", "scale=300:-1",
"-vframes", "1",
thumbnail_path
- ]
+ )
process = subprocess.Popen(
args,
stdout=subprocess.PIPE,
diff --git a/openpype/hosts/hiero/plugins/publish/extract_frames.py b/openpype/hosts/hiero/plugins/publish/extract_frames.py
index f865d2fb398..803c3387667 100644
--- a/openpype/hosts/hiero/plugins/publish/extract_frames.py
+++ b/openpype/hosts/hiero/plugins/publish/extract_frames.py
@@ -2,7 +2,7 @@
import pyblish.api
from openpype.lib import (
- get_oiio_tools_path,
+ get_oiio_tool_args,
run_subprocess,
)
from openpype.pipeline import publish
@@ -18,7 +18,7 @@ class ExtractFrames(publish.Extractor):
movie_extensions = ["mov", "mp4"]
def process(self, instance):
- oiio_tool_path = get_oiio_tools_path()
+ oiio_tool_args = get_oiio_tool_args("oiiotool")
staging_dir = self.staging_dir(instance)
output_template = os.path.join(staging_dir, instance.data["name"])
sequence = instance.context.data["activeTimeline"]
@@ -36,7 +36,7 @@ def process(self, instance):
output_path = output_template
output_path += ".{:04d}.{}".format(int(frame), output_ext)
- args = [oiio_tool_path]
+ args = list(oiio_tool_args)
ext = os.path.splitext(input_path)[1][1:]
if ext in self.movie_extensions:
diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py
index 7c6122cffee..1f9fef7417b 100644
--- a/openpype/hosts/houdini/api/creator_node_shelves.py
+++ b/openpype/hosts/houdini/api/creator_node_shelves.py
@@ -57,29 +57,32 @@ def create_interactive(creator_identifier, **kwargs):
list: The created instances.
"""
+ host = registered_host()
+ context = CreateContext(host)
+ creator = context.manual_creators.get(creator_identifier)
+ if not creator:
+ 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='Main',
- title="Define variant",
- help="Set the variant for the "
- "publish instance",
- close_choice=1)
+ 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.")
- host = registered_host()
- context = CreateContext(host)
- creator = context.manual_creators.get(creator_identifier)
- if not creator:
- raise RuntimeError("Invalid creator identifier: "
- "{}".format(creator_identifier))
-
# TODO: Once more elaborate unique create behavior should exist per Creator
# instead of per network editor area then we should move this from here
# to a method on the Creators for which this could be the default
diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py
index b03f8c8fc16..75c7ff9fee0 100644
--- a/openpype/hosts/houdini/api/lib.py
+++ b/openpype/hosts/houdini/api/lib.py
@@ -22,9 +22,12 @@
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"]["fps"]
def set_id(node, unique_id, overwrite=False):
@@ -472,14 +475,19 @@ def maintained_selection():
def reset_framerange():
- """Set frame range to current asset"""
+ """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")
@@ -493,6 +501,9 @@ 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)
hou.setFrame(frame_start)
diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py
index 8a26bbb5040..3c325edfa7d 100644
--- a/openpype/hosts/houdini/api/pipeline.py
+++ b/openpype/hosts/houdini/api/pipeline.py
@@ -25,7 +25,6 @@
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()
diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py
index 1e7eaa7e22e..70c837205ed 100644
--- a/openpype/hosts/houdini/api/plugin.py
+++ b/openpype/hosts/houdini/api/plugin.py
@@ -167,9 +167,12 @@ def create_instance_node(
class HoudiniCreator(NewCreator, HoudiniCreatorBase):
"""Base class for most of the Houdini creator plugins."""
selected_nodes = []
+ settings_name = None
def create(self, subset_name, instance_data, pre_create_data):
try:
+ self.selected_nodes = []
+
if pre_create_data.get("use_selection"):
self.selected_nodes = hou.selectedNodes()
@@ -292,3 +295,21 @@ def get_network_categories(self):
"""
return [hou.ropNodeTypeCategory()]
+
+ def apply_settings(self, project_settings, system_settings):
+ """Method called on initialization of plugin to apply settings."""
+
+ settings_name = self.settings_name
+ if settings_name is None:
+ settings_name = self.__class__.__name__
+
+ settings = project_settings["houdini"]["create"]
+ settings = settings.get(settings_name)
+ if settings is None:
+ self.log.debug(
+ "No settings found for {}".format(self.__class__.__name__)
+ )
+ return
+
+ for key, value in settings.items():
+ setattr(self, key, value)
diff --git a/openpype/hosts/houdini/hooks/set_paths.py b/openpype/hosts/houdini/hooks/set_paths.py
index 04a33b16431..b23659e23b5 100644
--- a/openpype/hosts/houdini/hooks/set_paths.py
+++ b/openpype/hosts/houdini/hooks/set_paths.py
@@ -1,4 +1,4 @@
-from openpype.lib import PreLaunchHook
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
class SetPath(PreLaunchHook):
@@ -6,7 +6,8 @@ class SetPath(PreLaunchHook):
Hook `GlobalHostDataHook` must be executed before this hook.
"""
- app_groups = ["houdini"]
+ app_groups = {"houdini"}
+ launch_types = {LaunchTypes.local}
def execute(self):
workdir = self.launch_context.env.get("AVALON_WORKDIR", "")
diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py
index 8b310753d02..12d08f7d838 100644
--- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py
+++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py
@@ -10,9 +10,10 @@ class CreateArnoldAss(plugin.HoudiniCreator):
label = "Arnold ASS"
family = "ass"
icon = "magic"
- defaults = ["Main"]
# Default extension: `.ass` or `.ass.gz`
+ # however calling HoudiniCreator.create()
+ # will override it by the value in the project settings
ext = ".ass"
def create(self, subset_name, instance_data, pre_create_data):
diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py
index bddf26dbd50..b58c377a204 100644
--- a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py
+++ b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py
@@ -1,5 +1,5 @@
from openpype.hosts.houdini.api import plugin
-from openpype.lib import EnumDef
+from openpype.lib import EnumDef, BoolDef
class CreateArnoldRop(plugin.HoudiniCreator):
@@ -9,7 +9,6 @@ class CreateArnoldRop(plugin.HoudiniCreator):
label = "Arnold ROP"
family = "arnold_rop"
icon = "magic"
- defaults = ["master"]
# Default extension
ext = "exr"
@@ -24,7 +23,7 @@ def create(self, subset_name, instance_data, pre_create_data):
# Add chunk size attribute
instance_data["chunkSize"] = 1
# Submit for job publishing
- instance_data["farm"] = True
+ instance_data["farm"] = pre_create_data.get("farm")
instance = super(CreateArnoldRop, self).create(
subset_name,
@@ -64,6 +63,9 @@ def get_pre_create_attr_defs(self):
]
return attrs + [
+ BoolDef("farm",
+ label="Submitting to Farm",
+ default=True),
EnumDef("image_format",
image_format_enum,
default=self.ext,
diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py
index a1101fd0452..a3f31e7e94c 100644
--- a/openpype/hosts/houdini/plugins/create/create_bgeo.py
+++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py
@@ -8,7 +8,7 @@
class CreateBGEO(plugin.HoudiniCreator):
"""BGEO pointcache creator."""
identifier = "io.openpype.creators.houdini.bgeo"
- label = "BGEO PointCache"
+ label = "PointCache (Bgeo)"
family = "pointcache"
icon = "gears"
diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py
index edfb992e1a0..4e1360ca45a 100644
--- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py
+++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py
@@ -11,7 +11,6 @@ class CreateKarmaROP(plugin.HoudiniCreator):
label = "Karma ROP"
family = "karma_rop"
icon = "magic"
- defaults = ["master"]
def create(self, subset_name, instance_data, pre_create_data):
import hou # noqa
@@ -21,7 +20,7 @@ def create(self, subset_name, instance_data, pre_create_data):
# Add chunk size attribute
instance_data["chunkSize"] = 10
# Submit for job publishing
- instance_data["farm"] = True
+ instance_data["farm"] = pre_create_data.get("farm")
instance = super(CreateKarmaROP, self).create(
subset_name,
@@ -67,6 +66,7 @@ def create(self, subset_name, instance_data, pre_create_data):
camera = None
for node in self.selected_nodes:
if node.type().name() == "cam":
+ camera = node.path()
has_camera = pre_create_data.get("cam_res")
if has_camera:
res_x = node.evalParm("resx")
@@ -96,6 +96,9 @@ def get_pre_create_attr_defs(self):
]
return attrs + [
+ BoolDef("farm",
+ label="Submitting to Farm",
+ default=True),
EnumDef("image_format",
image_format_enum,
default="exr",
diff --git a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py
index 5ca53e96de9..d2f0e735a88 100644
--- a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py
+++ b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py
@@ -11,7 +11,6 @@ class CreateMantraROP(plugin.HoudiniCreator):
label = "Mantra ROP"
family = "mantra_rop"
icon = "magic"
- defaults = ["master"]
def create(self, subset_name, instance_data, pre_create_data):
import hou # noqa
@@ -21,7 +20,7 @@ def create(self, subset_name, instance_data, pre_create_data):
# Add chunk size attribute
instance_data["chunkSize"] = 10
# Submit for job publishing
- instance_data["farm"] = True
+ instance_data["farm"] = pre_create_data.get("farm")
instance = super(CreateMantraROP, self).create(
subset_name,
@@ -76,6 +75,9 @@ def get_pre_create_attr_defs(self):
]
return attrs + [
+ BoolDef("farm",
+ label="Submitting to Farm",
+ default=True),
EnumDef("image_format",
image_format_enum,
default="exr",
diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py
index 554d5f2016b..7eaf2aff2ba 100644
--- a/openpype/hosts/houdini/plugins/create/create_pointcache.py
+++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py
@@ -8,7 +8,7 @@
class CreatePointCache(plugin.HoudiniCreator):
"""Alembic ROP to pointcache"""
identifier = "io.openpype.creators.houdini.pointcache"
- label = "Point Cache"
+ label = "PointCache (Abc)"
family = "pointcache"
icon = "gears"
diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py
index 4576e9a7214..1b8826a932c 100644
--- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py
+++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py
@@ -3,7 +3,7 @@
import hou # noqa
from openpype.hosts.houdini.api import plugin
-from openpype.lib import EnumDef
+from openpype.lib import EnumDef, BoolDef
class CreateRedshiftROP(plugin.HoudiniCreator):
@@ -13,7 +13,6 @@ class CreateRedshiftROP(plugin.HoudiniCreator):
label = "Redshift ROP"
family = "redshift_rop"
icon = "magic"
- defaults = ["master"]
ext = "exr"
def create(self, subset_name, instance_data, pre_create_data):
@@ -23,7 +22,7 @@ def create(self, subset_name, instance_data, pre_create_data):
# Add chunk size attribute
instance_data["chunkSize"] = 10
# Submit for job publishing
- instance_data["farm"] = True
+ instance_data["farm"] = pre_create_data.get("farm")
instance = super(CreateRedshiftROP, self).create(
subset_name,
@@ -100,6 +99,9 @@ def get_pre_create_attr_defs(self):
]
return attrs + [
+ BoolDef("farm",
+ label="Submitting to Farm",
+ default=True),
EnumDef("image_format",
image_format_enum,
default=self.ext,
diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py
index c015cebd49b..9c96e48e3a4 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 @@ def create(self, subset_name, instance_data, pre_create_data):
}
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 @@ def get_network_categories(self):
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'))
diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py
index 1de9be4ed61..793a544fdff 100644
--- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py
+++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py
@@ -14,8 +14,6 @@ class CreateVrayROP(plugin.HoudiniCreator):
label = "VRay ROP"
family = "vray_rop"
icon = "magic"
- defaults = ["master"]
-
ext = "exr"
def create(self, subset_name, instance_data, pre_create_data):
@@ -25,7 +23,7 @@ def create(self, subset_name, instance_data, pre_create_data):
# Add chunk size attribute
instance_data["chunkSize"] = 10
# Submit for job publishing
- instance_data["farm"] = True
+ instance_data["farm"] = pre_create_data.get("farm")
instance = super(CreateVrayROP, self).create(
subset_name,
@@ -139,6 +137,9 @@ def get_pre_create_attr_defs(self):
]
return attrs + [
+ BoolDef("farm",
+ label="Submitting to Farm",
+ default=True),
EnumDef("image_format",
image_format_enum,
default=self.ext,
diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py
index 57edc341a3d..9630716253c 100644
--- a/openpype/hosts/houdini/plugins/load/load_hda.py
+++ b/openpype/hosts/houdini/plugins/load/load_hda.py
@@ -59,6 +59,9 @@ def update(self, container, representation):
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"]
diff --git a/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py b/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py
index 6c527377e0a..3323e97c206 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py
@@ -17,5 +17,5 @@ class CollectPointcacheType(pyblish.api.InstancePlugin):
def process(self, instance):
if instance.data["creator_identifier"] == "io.openpype.creators.houdini.bgeo": # noqa: E501
instance.data["families"] += ["bgeo"]
- elif instance.data["creator_identifier"] == "io.openpype.creators.houdini.alembic": # noqa: E501
+ elif instance.data["creator_identifier"] == "io.openpype.creators.houdini.pointcache": # noqa: E501
instance.data["families"] += ["abc"]
diff --git a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py
deleted file mode 100644
index 22746aabb03..00000000000
--- a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# -*- coding: utf-8 -*-
-"""Validator plugin for SOP Path in bgeo isntance."""
-import pyblish.api
-from openpype.pipeline import PublishValidationError
-
-
-class ValidateNoSOPPath(pyblish.api.InstancePlugin):
- """Validate if SOP Path in BGEO instance exists."""
-
- order = pyblish.api.ValidatorOrder
- families = ["bgeo"]
- label = "Validate BGEO SOP Path"
-
- def process(self, instance):
-
- import hou
-
- node = hou.node(instance.data.get("instance_node"))
- sop_path = node.evalParm("soppath")
- if not sop_path:
- raise PublishValidationError(
- ("Empty SOP Path ('soppath' parameter) found in "
- f"the BGEO instance Geometry - {node.path()}"))
- if not isinstance(hou.node(sop_path), hou.SopNode):
- raise PublishValidationError(
- "SOP path is not pointing to valid SOP node.")
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 ca06617ab00..471fa5b6d13 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py
@@ -32,8 +32,9 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin):
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
+ nodes = [n.path() for n in invalid]
raise PublishValidationError(
- "See log for details. " "Invalid nodes: {0}".format(invalid),
+ "See log for details. " "Invalid nodes: {0}".format(nodes),
title=self.label
)
diff --git a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py
index 543c8e1407a..afe05e31732 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py
@@ -7,8 +7,6 @@
)
from openpype.pipeline.publish import RepairAction
-from openpype.pipeline.publish import RepairAction
-
class ValidateWorkfilePaths(
pyblish.api.InstancePlugin, OptionalPyblishPluginMixin):
diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml
index 47a4653d5d7..5818a117eb2 100644
--- a/openpype/hosts/houdini/startup/MainMenuCommon.xml
+++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml
@@ -2,7 +2,19 @@