mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into feature/OP-3129_houdini-bgeo-publishing
This commit is contained in:
commit
99c0031fbb
1055 changed files with 32882 additions and 23010 deletions
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -35,6 +35,8 @@ body:
|
|||
label: Version
|
||||
description: What version are you running? Look to OpenPype Tray
|
||||
options:
|
||||
- 3.16.0
|
||||
- 3.16.0-nightly.1
|
||||
- 3.15.12
|
||||
- 3.15.12-nightly.4
|
||||
- 3.15.12-nightly.3
|
||||
|
|
@ -133,8 +135,6 @@ body:
|
|||
- 3.14.5
|
||||
- 3.14.5-nightly.3
|
||||
- 3.14.5-nightly.2
|
||||
- 3.14.5-nightly.1
|
||||
- 3.14.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -37,6 +37,7 @@ Temporary Items
|
|||
###########
|
||||
/build
|
||||
/dist/
|
||||
/server_addon/package/*
|
||||
|
||||
/vendor/bin/*
|
||||
/vendor/python/*
|
||||
|
|
|
|||
848
CHANGELOG.md
848
CHANGELOG.md
|
|
@ -1,6 +1,854 @@
|
|||
# Changelog
|
||||
|
||||
|
||||
## [3.16.0](https://github.com/ynput/OpenPype/tree/3.16.0)
|
||||
|
||||
|
||||
[Full Changelog](https://github.com/ynput/OpenPype/compare/...3.16.0)
|
||||
|
||||
### **🆕 New features**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Reduce usage of legacy io <a href="https://github.com/ynput/OpenPype/pull/4723">#4723</a></summary>
|
||||
|
||||
Replace usages of `legacy_io` with getter methods or reuse already available information. Create plugins using CreateContext are using context from CreateContext object. Loaders are usign getter function from context tools. Publish plugin are using information instance.data or context.data. In some cases were pieces of code refactored a little e.g. fps getter in maya.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Documentation: API docs reborn - yet again <a href="https://github.com/ynput/OpenPype/pull/4419">#4419</a></summary>
|
||||
|
||||
## Feature
|
||||
|
||||
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.
|
||||
|
||||
## How to use it
|
||||
|
||||
You can run:
|
||||
|
||||
```sh
|
||||
cd .\docs
|
||||
make.bat html
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```sh
|
||||
cd ./docs
|
||||
make html
|
||||
```
|
||||
|
||||
This will go over our code and generate **.rst** files in `/docs/source/autoapi` and from those it will generate full html documentation in `/docs/build/html`.
|
||||
|
||||
During the build you'll see tons of red errors that are pointing to our issues:
|
||||
|
||||
1) **Wrong imports**
|
||||
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
|
||||
`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.
|
||||
|
||||
|
||||
## Editing rst templates
|
||||
|
||||
Everything starts with `/docs/source/index.rst` - this file should be properly edited, Right now it just includes `readme.rst` that in turn include and parse main `README.md`. This is entrypoint to API documentation. All templates generated by AutoAPI are in `/docs/source/autoapi`. They should be eventually commited to repository and edited too.
|
||||
|
||||
## Steps for enhancing API documentation
|
||||
|
||||
1) Run `/docs/make.bat html`
|
||||
2) Read the red errors/warnings - fix it in the code
|
||||
3) Run `/docs/make.bat html` again until there are not red lines
|
||||
4) Edit rst files and add some meaningfull content there
|
||||
|
||||
> **Note**
|
||||
> This can (should) be merged as is without doc-string fixes in the code or changes in templates. All additional improvements on API documentation should be made in new PRs.
|
||||
|
||||
> **Warning**
|
||||
> You need to add new dependencies to use it. Run `create_venv`.
|
||||
|
||||
Connected to #2490
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Global: custom location for OP local versions <a href="https://github.com/ynput/OpenPype/pull/4673">#4673</a></summary>
|
||||
|
||||
This provides configurable location to unzip Openpype version zips. By default, it was hardcoded to artist's app data folder, which might be problematic/slow with roaming profiles.Location must be accessible by user running OP Tray with write permissions (so `Program Files` might be problematic)
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Update settings conversion <a href="https://github.com/ynput/OpenPype/pull/4837">#4837</a></summary>
|
||||
|
||||
Updated conversion script of AYON settings to v3 settings. PR is related to changes in addons repository https://github.com/ynput/ayon-addons/pull/6 . Changed how the conversion happens -> conversion output does not start with openpype defaults but as empty dictionary.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Implement integrate links publish plugin <a href="https://github.com/ynput/OpenPype/pull/4842">#4842</a></summary>
|
||||
|
||||
Implemented entity links get/create functions. Added new integrator which replaces v3 integrator for links.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Version attributes integration <a href="https://github.com/ynput/OpenPype/pull/4991">#4991</a></summary>
|
||||
|
||||
Implemented unified integrate plugin to update version attributes after all integrations for AYON. The goal is to be able update attribute values in a unified way to a version when all addon integrators are done, so e.g. ftrack can add ftrack id to matching version in AYON server etc.The can be stored under `"versionAttributes"` key.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Staging versions can be used <a href="https://github.com/ynput/OpenPype/pull/4992">#4992</a></summary>
|
||||
|
||||
Added ability to use staging versions in AYON mode.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Preparation for products <a href="https://github.com/ynput/OpenPype/pull/5038">#5038</a></summary>
|
||||
|
||||
Prepare ayon settings conversion script for `product` settings conversion.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Loader: Hide inactive versions in UI <a href="https://github.com/ynput/OpenPype/pull/5101">#5101</a></summary>
|
||||
|
||||
Added support for `active` argument to hide versions with active set to False in Loader UI when in AYON mode.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: CLI addon command <a href="https://github.com/ynput/OpenPype/pull/5109">#5109</a></summary>
|
||||
|
||||
Added `addon` alias for `module` in OpenPype cli commands.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: OpenPype as server addon <a href="https://github.com/ynput/OpenPype/pull/5199">#5199</a></summary>
|
||||
|
||||
OpenPype repository can be converted to AYON addon for distribution. Addon has defined dependencies that are required to use it and are not in base ayon-launcher (desktop application).
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Runtime dependencies <a href="https://github.com/ynput/OpenPype/pull/5206">#5206</a></summary>
|
||||
|
||||
Defined runtime dependencies in pyproject toml. Moved python ocio and otio modules there.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Bundle distribution <a href="https://github.com/ynput/OpenPype/pull/5209">#5209</a></summary>
|
||||
|
||||
Since AYON server 0.3.0 are addon versions defined by bundles which affects how addons, dependency packages and installers are handled. Only source of truth, about any version of anything that should be used, is server bundle.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Feature/blender handle q application <a href="https://github.com/ynput/OpenPype/pull/5264">#5264</a></summary>
|
||||
|
||||
This edit is to change the way the QApplication is run for Blender. It calls in the singleton (QApplication) during the register. This is made so that other Qt applications and addons are able to run on Blender. In its current implementation, if a QApplication is already running, all functionality of OpenPype becomes unavailable.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **🚀 Enhancements**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Connect to AYON server (base) <a href="https://github.com/ynput/OpenPype/pull/3924">#3924</a></summary>
|
||||
|
||||
<strong>Initial implementation of being able use AYON server in current OpenPype client. Added ability to connect to AYON server and use base queries.
|
||||
|
||||
</strong>AYON mode has it's own executable (and start script). To start in AYON mode just replace `start.py` with `ayon_start.py` (added tray start script to tools). Added constant `AYON_SERVER_ENABLED` to `openpype/__init__.py` to know if ayon mode is enabled. In that case Mongo is not used at all and any attempts will cause crashes.I had to modify `~/openpype/client` content to be able do this switch. Mongo implementation was moved to `mongo` subfolder and use "star imports" in files from where current imports are used. Logic of any tool or query in code was not changed at all. Since functions were based on mongo queries they don't use full potential of AYON server abilities.ATM implementation has login UI, distribution of files from server and replacement of mongo queries. For queries is used `ayon_api` module. Which is in live development so the versions may change from day to day.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Enhancement kitsu note with exceptions <a href="https://github.com/ynput/OpenPype/pull/4537">#4537</a></summary>
|
||||
|
||||
Adding a setting to choose some exceptions to IntegrateKitsuNote task status changes.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Environment variable for default OCIO configs <a href="https://github.com/ynput/OpenPype/pull/4670">#4670</a></summary>
|
||||
|
||||
Define environment variable which lead to root of builtin ocio configs to be able change the root without changing settings. For the path in settings was used `"{OPENPYPE_ROOT}/vendor/bin/ocioconfig/OpenColorIOConfig"` which disallow to change the root somewhere else. That will be needed in AYON where configs won't be part of desktop application but downloaded from server.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Editorial hierarchy creation <a href="https://github.com/ynput/OpenPype/pull/4699">#4699</a></summary>
|
||||
|
||||
Implemented extract hierarchy to AYON plugin which created entities in AYON using ayon api.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Vendorize ayon api <a href="https://github.com/ynput/OpenPype/pull/4753">#4753</a></summary>
|
||||
|
||||
Vendorize ayon api into openpype vendor directory. The reason is that `ayon-python-api` is in live development and will fix/add features often in next few weeks/months, and because update of dependency requires new release -> new build, we want to avoid the need of doing that as it would affect OpenPype development.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Update PySide 6 for MacOs <a href="https://github.com/ynput/OpenPype/pull/4764">#4764</a></summary>
|
||||
|
||||
New version of PySide6 does not have issues with settings UI. It is still breaking UI stylesheets so it is not changed for other plaforms but it is enhancement from previous state.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Removed unused cli commands <a href="https://github.com/ynput/OpenPype/pull/4902">#4902</a></summary>
|
||||
|
||||
Removed `texturecopy` and `launch` cli commands from cli commands.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Linux & MacOS launch script <a href="https://github.com/ynput/OpenPype/pull/4970">#4970</a></summary>
|
||||
|
||||
Added shell script to launch tray in AYON mode.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Qt scale enhancement <a href="https://github.com/ynput/OpenPype/pull/5059">#5059</a></summary>
|
||||
|
||||
Set ~~'QT_SCALE_FACTOR_ROUNDING_POLICY'~~ scale factor rounding policy of QApplication to `PassThrough` so the scaling can be 'float' number and not just 'int' (150% -> 1.5 scale).
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>CI: WPS linting instead of Hound (rebase) 2 <a href="https://github.com/ynput/OpenPype/pull/5115">#5115</a></summary>
|
||||
|
||||
Because Hound currently used to lint the code on GH ships with really old flake8 support, it fails miserably on any newer Python syntax. This PR is adding WPS linter to GitHub workflows that should step in.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Max: OP parameters only displays what is attached to the container <a href="https://github.com/ynput/OpenPype/pull/5229">#5229</a></summary>
|
||||
|
||||
The OP parameter in 3dsmax only displays what is currently attached to the container while deleting while you can see the items which is not added when you are adding to the container.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Testing: improving logging during testing <a href="https://github.com/ynput/OpenPype/pull/5271">#5271</a></summary>
|
||||
|
||||
Unit testing logging was crashing on more then one nested layers of inherited loggers.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: removing deprecated settings in baking <a href="https://github.com/ynput/OpenPype/pull/5275">#5275</a></summary>
|
||||
|
||||
Removing deprecated settings for baking with reformat. This option was only for single reformat node and it had been substituted with multiple reposition nodes.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **🐛 Bug fixes**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: General fixes and updates <a href="https://github.com/ynput/OpenPype/pull/4975">#4975</a></summary>
|
||||
|
||||
Few smaller fixes related to AYON connection. Some of fixes were taken from this PR.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Start script: Change returncode on validate or list versions <a href="https://github.com/ynput/OpenPype/pull/4515">#4515</a></summary>
|
||||
|
||||
<strong>Change exit code from `1` to `0` when versions are printed or when version is validated.
|
||||
|
||||
</strong>Return code `1` is indicating error but there didn't happen any error.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Change login UI works <a href="https://github.com/ynput/OpenPype/pull/4754">#4754</a></summary>
|
||||
|
||||
Fixed change of login UI. Logic change UI did show up, new login was successful, but after restart was used the previous login. This change fix the issue.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: General issues <a href="https://github.com/ynput/OpenPype/pull/4763">#4763</a></summary>
|
||||
|
||||
Vendorized `ayon_api` from PR broke OpenPype launch, because `ayon_api` is not available. Moved `ayon_api` from ayon specific subforlder to `common` python vendor in OpenPype, and removed login in ayon start script (which was invalid anyway). Also made fixed compatibility with PySide6 by using `qtpy` instead of `Qt` and changing code which is not PySide6 compatible.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Small fixes <a href="https://github.com/ynput/OpenPype/pull/4841">#4841</a></summary>
|
||||
|
||||
Bugsfixes and enhancements related to AYON logic. Define `BUILTIN_OCIO_ROOT` environment variable so OCIO configs are working. Use constants from ayon api instead of hardcoding them in codebase. Change process name from "openpype" to "ayon". Don't execute login dialog when application is not yet running but use `open` method instead. Fixed missing modules settings which were not taken from openpype defaults. Updated ayon api to `0.1.17`.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Bugfix - Update gazu to 0.9.3 <a href="https://github.com/ynput/OpenPype/pull/4845">#4845</a></summary>
|
||||
|
||||
This updates Gazu to 0.9.3 to make sure Gazu works with Kitsu and Zou 0.16.x+
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Igniter: fix error reports in silent mode <a href="https://github.com/ynput/OpenPype/pull/4909">#4909</a></summary>
|
||||
|
||||
Some errors in silent mode commands in Igniter were suppressed and not visible for example in Deadline log.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Remove ayon api from poetry lock <a href="https://github.com/ynput/OpenPype/pull/4964">#4964</a></summary>
|
||||
|
||||
Remove AYON python api from pyproject.toml and poetry.lock again.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Ftrack: Fix AYON settings conversion <a href="https://github.com/ynput/OpenPype/pull/4967">#4967</a></summary>
|
||||
|
||||
Fix conversion of ftrack settings in AYON mode.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: ISO date format conversion issues <a href="https://github.com/ynput/OpenPype/pull/4981">#4981</a></summary>
|
||||
|
||||
Function `datetime.fromisoformat` was replaced with `arrow.get` to be used instead.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Missing files on representations <a href="https://github.com/ynput/OpenPype/pull/4989">#4989</a></summary>
|
||||
|
||||
Fix integration of files into representation in server database.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Fix Python 2 vendor for arrow <a href="https://github.com/ynput/OpenPype/pull/4993">#4993</a></summary>
|
||||
|
||||
Moved remaining dependencies for arrow from ftrack to python 2 vendor.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Fix new load plugins for next minor relase <a href="https://github.com/ynput/OpenPype/pull/5000">#5000</a></summary>
|
||||
|
||||
Fix access to `fname` attribute which is not available on load plugin anymore.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Fix mongo secure connection <a href="https://github.com/ynput/OpenPype/pull/5031">#5031</a></summary>
|
||||
|
||||
Fix `ssl` and `tls` keys checks in mongo uri query string.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Fix site sync settings <a href="https://github.com/ynput/OpenPype/pull/5069">#5069</a></summary>
|
||||
|
||||
Fixed settings for AYON variant of sync server.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Replace deprecated keyword argument in PyMongo <a href="https://github.com/ynput/OpenPype/pull/5080">#5080</a></summary>
|
||||
|
||||
Use argument `tlsCAFile` instead of `ssl_ca_certs` to avoid deprecation warnings.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Igniter: QApplication is created <a href="https://github.com/ynput/OpenPype/pull/5081">#5081</a></summary>
|
||||
|
||||
Function `_get_qt_app` actually creates new `QApplication` if was not created yet.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Lower unidecode version <a href="https://github.com/ynput/OpenPype/pull/5090">#5090</a></summary>
|
||||
|
||||
Use older version of Unidecode module to support Python 2.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Lower cryptography to 39.0.0 <a href="https://github.com/ynput/OpenPype/pull/5099">#5099</a></summary>
|
||||
|
||||
Lower cryptography to 39.0.0 to avoid breaking of DCCs like Maya and Nuke.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Global environments key fix <a href="https://github.com/ynput/OpenPype/pull/5118">#5118</a></summary>
|
||||
|
||||
Seems that when converting ayon settings to OP settings the `environments` setting is put under the `environments` key in `general` however when populating the environment the `environment` key gets picked up, which does not contain the environment variables from the `core/environments` setting
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Add collector to tray publisher for getting frame range data <a href="https://github.com/ynput/OpenPype/pull/5152">#5152</a></summary>
|
||||
|
||||
Add collector to tray publisher to get frame range data. User can choose to enable this collector if they need this in the publisher.Resolve #5136
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Unreal: get current project settings not using unreal project name <a href="https://github.com/ynput/OpenPype/pull/5170">#5170</a></summary>
|
||||
|
||||
There was a bug where Unreal project name was used to query project settings. But Unreal project name can differ from the "real" one because of naming convention rules set by Unreal. This is fixing it by asking for current project settings.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Substance Painter: Fix Collect Texture Set Images unable to copy.deepcopy due to QMenu <a href="https://github.com/ynput/OpenPype/pull/5238">#5238</a></summary>
|
||||
|
||||
Fix `copy.deepcopy` of `instance.data`.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Ayon: server returns different key <a href="https://github.com/ynput/OpenPype/pull/5251">#5251</a></summary>
|
||||
|
||||
Package returned from server has `filename` instead of `name`.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Substance Painter: Fix default color management settings <a href="https://github.com/ynput/OpenPype/pull/5259">#5259</a></summary>
|
||||
|
||||
The default settings for color management for Substance Painter were invalid, it was set to override the global config by default but specified no valid config paths of its own - and thus errored that the paths were not correct.This sets the defaults correctly to match other hosts._I quickly checked - this seems to be the only host with the wrong default settings_
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: fixing container data if windows path in value <a href="https://github.com/ynput/OpenPype/pull/5267">#5267</a></summary>
|
||||
|
||||
Windows path in container data are reformatted. Previously it was reported that Nuke was rising `utf8 0xc0` error if backward slashes were in data values.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Houdini: fix typo error in collect arnold rop <a href="https://github.com/ynput/OpenPype/pull/5281">#5281</a></summary>
|
||||
|
||||
Fixing a typo error in `collect_arnold_rop.py`Reference: #5280
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Slack - enhanced logging and protection against failure <a href="https://github.com/ynput/OpenPype/pull/5287">#5287</a></summary>
|
||||
|
||||
Covered issues found in production on customer site. SlackAPI exception doesn't need to have 'error', covered uncaught exception.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Removed unnecessary import of pyblish.cli <a href="https://github.com/ynput/OpenPype/pull/5292">#5292</a></summary>
|
||||
|
||||
This import resulted in adding additional logging handler which lead to duplication of logs in hosts with plugins containing `is_in_tests` method. Import is unnecessary for testing functionality.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **🔀 Refactored code**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Loader: Remove `context` argument from Loader.__init__() <a href="https://github.com/ynput/OpenPype/pull/4602">#4602</a></summary>
|
||||
|
||||
Remove the previously required `context` argument.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Global: Remove legacy integrator <a href="https://github.com/ynput/OpenPype/pull/4786">#4786</a></summary>
|
||||
|
||||
Remove the legacy integrator.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **📃 Documentation**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Next Minor Release <a href="https://github.com/ynput/OpenPype/pull/5291">#5291</a></summary>
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **Merged pull requests**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Refactor to new publisher <a href="https://github.com/ynput/OpenPype/pull/4388">#4388</a></summary>
|
||||
|
||||
<strong>**Refactor Maya to use the new publisher with new creators.**
|
||||
|
||||
</strong>
|
||||
- [x] Legacy instance can be converted in UI using `SubsetConvertorPlugin`
|
||||
- [x] Fix support for old style "render" and "vrayscene" instance to the new per layer format.
|
||||
- [x] Context data is stored with scene
|
||||
- [x] Workfile instance converted to AutoCreator
|
||||
- [x] Converted Creator classes
|
||||
- [x] Create animation
|
||||
- [x] Create ass
|
||||
- [x] Create assembly
|
||||
- [x] Create camera
|
||||
- [x] Create layout
|
||||
- [x] Create look
|
||||
- [x] Create mayascene
|
||||
- [x] Create model
|
||||
- [x] Create multiverse look
|
||||
- [x] Create multiverse usd
|
||||
- [x] Create multiverse usd comp
|
||||
- [x] Create multiverse usd over
|
||||
- [x] Create pointcache
|
||||
- [x] Create proxy abc
|
||||
- [x] Create redshift proxy
|
||||
- [x] Create render
|
||||
- [x] Create rendersetup
|
||||
- [x] Create review
|
||||
- [x] Create rig
|
||||
- [x] Create setdress
|
||||
- [x] Create unreal skeletalmesh
|
||||
- [x] Create unreal staticmesh
|
||||
- [x] Create vrayproxy
|
||||
- [x] Create vrayscene
|
||||
- [x] Create xgen
|
||||
- [x] Create yeti cache
|
||||
- [x] Create yeti rig
|
||||
- [ ] Tested new Creator publishes
|
||||
- [x] Publish animation
|
||||
- [x] Publish ass
|
||||
- [x] Publish assembly
|
||||
- [x] Publish camera
|
||||
- [x] Publish layout
|
||||
- [x] Publish look
|
||||
- [x] Publish mayascene
|
||||
- [x] Publish model
|
||||
- [ ] Publish multiverse look
|
||||
- [ ] Publish multiverse usd
|
||||
- [ ] Publish multiverse usd comp
|
||||
- [ ] Publish multiverse usd over
|
||||
- [x] Publish pointcache
|
||||
- [x] Publish proxy abc
|
||||
- [x] Publish redshift proxy
|
||||
- [x] Publish render
|
||||
- [x] Publish rendersetup
|
||||
- [x] Publish review
|
||||
- [x] Publish rig
|
||||
- [x] Publish setdress
|
||||
- [x] Publish unreal skeletalmesh
|
||||
- [x] Publish unreal staticmesh
|
||||
- [x] Publish vrayproxy
|
||||
- [x] Publish vrayscene
|
||||
- [x] Publish xgen
|
||||
- [x] Publish yeti cache
|
||||
- [x] Publish yeti rig
|
||||
- [x] Publish workfile
|
||||
- [x] Rig loader correctly generates a new style animation creator instance
|
||||
- [ ] Validations / Error messages for common validation failures look nice and usable as a report.
|
||||
- [ ] Make Create Animation hidden to the user (should not create manually?)
|
||||
- [x] Correctly detect difference between **'creator_attributes'** and **'instance_data'** since both are "flattened" to the top node.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Start script: Fix possible issues with destination drive path <a href="https://github.com/ynput/OpenPype/pull/4478">#4478</a></summary>
|
||||
|
||||
<strong>Drive paths for windows are fixing possibly missing slash at the end of destination path.
|
||||
|
||||
</strong>Windows `subst` command require to have destination path with slash if it's a drive (it should be `G:\` not `G:`).
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Global: Move PyOpenColorIO to vendor/python <a href="https://github.com/ynput/OpenPype/pull/4946">#4946</a></summary>
|
||||
|
||||
So that DCCs don't conflict with their own.
|
||||
|
||||
See https://github.com/ynput/OpenPype/pull/4267#issuecomment-1537153263 for the issue with Gaffer.
|
||||
|
||||
I'm not sure if this is the correct approach, but I assume PySide/Shiboken is under `vendor/python` for this reason as well...
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>RuntimeError with Click on deadline publish <a href="https://github.com/ynput/OpenPype/pull/5065">#5065</a></summary>
|
||||
|
||||
I changed Click to version 8.0 instead of 7.1.2 to solve this error:
|
||||
```
|
||||
2023-05-30 16:16:51: 0: STDOUT: Traceback (most recent call last):
|
||||
2023-05-30 16:16:51: 0: STDOUT: File "start.py", line 1126, in boot
|
||||
2023-05-30 16:16:51: 0: STDOUT: File "/prod/softprod/apps/openpype/LINUX/3.15/dependencies/click/core.py", line 829, in __call__
|
||||
2023-05-30 16:16:51: 0: STDOUT: return self.main(*args, **kwargs)
|
||||
2023-05-30 16:16:51: 0: STDOUT: File "/prod/softprod/apps/openpype/LINUX/3.15/dependencies/click/core.py", line 760, in main
|
||||
2023-05-30 16:16:51: 0: STDOUT: _verify_python3_env()
|
||||
2023-05-30 16:16:51: 0: STDOUT: File "/prod/softprod/apps/openpype/LINUX/3.15/dependencies/click/_unicodefun.py", line 126, in _verify_python3_env
|
||||
2023-05-30 16:16:51: 0: STDOUT: raise RuntimeError(
|
||||
2023-05-30 16:16:51: 0: STDOUT: RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII as encoding for the environment. Consult https://click.palletsprojects.com/python3/ for mitigation steps.
|
||||
```
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.15.12](https://github.com/ynput/OpenPype/tree/3.15.12)
|
||||
|
||||
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -3,7 +3,7 @@
|
|||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
OpenPype
|
||||
====
|
||||
========
|
||||
|
||||
[](https://github.com/pypeclub/pype/actions/workflows/documentation.yml) 
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ It can be built and ran on all common platforms. We develop and test on the foll
|
|||
For more details on requirements visit [requirements documentation](https://openpype.io/docs/dev_requirements)
|
||||
|
||||
Building OpenPype
|
||||
-------------
|
||||
-----------------
|
||||
|
||||
To build OpenPype you currently need [Python 3.9](https://www.python.org/downloads/) as we are following
|
||||
[vfx platform](https://vfxplatform.com). Because of some Linux distros comes with newer Python version
|
||||
|
|
@ -67,9 +67,9 @@ git clone --recurse-submodules git@github.com:Pypeclub/OpenPype.git
|
|||
|
||||
#### To build OpenPype:
|
||||
|
||||
1) Run `.\tools\create_env.ps1` to create virtual environment in `.\venv`
|
||||
1) Run `.\tools\create_env.ps1` to create virtual environment in `.\venv`.
|
||||
2) Run `.\tools\fetch_thirdparty_libs.ps1` to download third-party dependencies like ffmpeg and oiio. Those will be included in build.
|
||||
3) Run `.\tools\build.ps1` to build OpenPype executables in `.\build\`
|
||||
3) Run `.\tools\build.ps1` to build OpenPype executables in `.\build\`.
|
||||
|
||||
To create distributable OpenPype versions, run `./tools/create_zip.ps1` - that will
|
||||
create zip file with name `openpype-vx.x.x.zip` parsed from current OpenPype repository and
|
||||
|
|
@ -88,38 +88,38 @@ some OpenPype dependencies like [CMake](https://cmake.org/) and **XCode Command
|
|||
Easy way of installing everything necessary is to use [Homebrew](https://brew.sh):
|
||||
|
||||
1) Install **Homebrew**:
|
||||
```sh
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
```sh
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
2) Install **cmake**:
|
||||
```sh
|
||||
brew install cmake
|
||||
```
|
||||
```sh
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
3) Install [pyenv](https://github.com/pyenv/pyenv):
|
||||
```sh
|
||||
brew install pyenv
|
||||
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
|
||||
pyenv init
|
||||
exec "$SHELL"
|
||||
PATH=$(pyenv root)/shims:$PATH
|
||||
```
|
||||
```sh
|
||||
brew install pyenv
|
||||
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
|
||||
pyenv init
|
||||
exec "$SHELL"
|
||||
PATH=$(pyenv root)/shims:$PATH
|
||||
```
|
||||
|
||||
4) Pull in required Python version 3.9.x
|
||||
```sh
|
||||
# install Python build dependences
|
||||
brew install openssl readline sqlite3 xz zlib
|
||||
4) Pull in required Python version 3.9.x:
|
||||
```sh
|
||||
# install Python build dependences
|
||||
brew install openssl readline sqlite3 xz zlib
|
||||
|
||||
# replace with up-to-date 3.9.x version
|
||||
pyenv install 3.9.6
|
||||
```
|
||||
# replace with up-to-date 3.9.x version
|
||||
pyenv install 3.9.6
|
||||
```
|
||||
|
||||
5) Set local Python version
|
||||
```sh
|
||||
# switch to OpenPype source directory
|
||||
pyenv local 3.9.6
|
||||
```
|
||||
5) Set local Python version:
|
||||
```sh
|
||||
# switch to OpenPype source directory
|
||||
pyenv local 3.9.6
|
||||
```
|
||||
|
||||
#### To build OpenPype:
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ pyenv local 3.9.6
|
|||
|
||||
|
||||
Running OpenPype
|
||||
------------
|
||||
----------------
|
||||
|
||||
OpenPype can by executed either from live sources (this repository) or from
|
||||
*"frozen code"* - executables that can be build using steps described above.
|
||||
|
|
@ -289,7 +289,7 @@ To run tests, execute `.\tools\run_tests(.ps1|.sh)`.
|
|||
|
||||
|
||||
Developer tools
|
||||
-------------
|
||||
---------------
|
||||
|
||||
In case you wish to add your own tools to `.\tools` folder without git tracking, it is possible by adding it with `dev_*` suffix (example: `dev_clear_pyc(.ps1|.sh)`).
|
||||
|
||||
|
|
|
|||
483
ayon_start.py
Normal file
483
ayon_start.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
# -*- 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 `<frozen>/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()
|
||||
16
common/ayon_common/__init__.py
Normal file
16
common/ayon_common/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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",
|
||||
)
|
||||
511
common/ayon_common/connection/credentials.py
Normal file
511
common/ayon_common/connection/credentials.py
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
"""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()
|
||||
12
common/ayon_common/connection/ui/__init__.py
Normal file
12
common/ayon_common/connection/ui/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from .login_window import (
|
||||
ServerLoginWindow,
|
||||
ask_to_login,
|
||||
change_user,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ServerLoginWindow",
|
||||
"ask_to_login",
|
||||
"change_user",
|
||||
)
|
||||
23
common/ayon_common/connection/ui/__main__.py
Normal file
23
common/ayon_common/connection/ui/__main__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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])
|
||||
710
common/ayon_common/connection/ui/login_window.py
Normal file
710
common/ayon_common/connection/ui/login_window.py
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
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.<br/><br/>"
|
||||
"You can cancel logout and only change server and user login"
|
||||
" in login dialog.<br/><br/>"
|
||||
"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"<b>{exc.title}</b>"]
|
||||
parts.extend(f"- {hint}" for hint in exc.hints)
|
||||
self._set_message("<br/>".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 = ["<b>Invalid credentials</b>"]
|
||||
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("<br/>".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 = [
|
||||
"<b>Unexpected error happened</b>",
|
||||
"- Can be caused by wrong url (leading elsewhere)"
|
||||
]
|
||||
self._set_message("<br/>".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()
|
||||
47
common/ayon_common/connection/ui/widgets.py
Normal file
47
common/ayon_common/connection/ui/widgets.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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)
|
||||
|
|
@ -5,7 +5,7 @@ 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.
|
||||
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
|
||||
|
|
@ -15,4 +15,4 @@ Required part of addon distribution will be sharing of dependencies (python libr
|
|||
|
||||
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!
|
||||
This code needs to be independent on Openpype code as much as possible!
|
||||
9
common/ayon_common/distribution/__init__.py
Normal file
9
common/ayon_common/distribution/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .control import AyonDistribution, BundleNotFoundError
|
||||
from .utils import show_missing_bundle_information
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AyonDistribution",
|
||||
"BundleNotFoundError",
|
||||
"show_missing_bundle_information",
|
||||
)
|
||||
1116
common/ayon_common/distribution/control.py
Normal file
1116
common/ayon_common/distribution/control.py
Normal file
File diff suppressed because it is too large
Load diff
265
common/ayon_common/distribution/data_structures.py
Normal file
265
common/ayon_common/distribution/data_structures.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
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"],
|
||||
)
|
||||
250
common/ayon_common/distribution/downloaders.py
Normal file
250
common/ayon_common/distribution/downloaders.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
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
|
||||
|
|
@ -9,21 +9,23 @@ import hashlib
|
|||
import tarfile
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
|
||||
USER_AGENT = "openpype"
|
||||
USER_AGENT = "AYON-launcher"
|
||||
|
||||
|
||||
class RemoteFileHandler:
|
||||
"""Download file from url, might be GDrive shareable link"""
|
||||
|
||||
IMPLEMENTED_ZIP_FORMATS = ['zip', 'tar', 'tgz',
|
||||
'tar.gz', 'tar.xz', 'tar.bz2']
|
||||
IMPLEMENTED_ZIP_FORMATS = {
|
||||
"zip", "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def calculate_md5(fpath, chunk_size=10000):
|
||||
md5 = hashlib.md5()
|
||||
with open(fpath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(chunk_size), b''):
|
||||
with open(fpath, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(chunk_size), b""):
|
||||
md5.update(chunk)
|
||||
return md5.hexdigest()
|
||||
|
||||
|
|
@ -45,7 +47,7 @@ class RemoteFileHandler:
|
|||
h = hashlib.sha256()
|
||||
b = bytearray(128 * 1024)
|
||||
mv = memoryview(b)
|
||||
with open(fpath, 'rb', buffering=0) as f:
|
||||
with open(fpath, "rb", buffering=0) as f:
|
||||
for n in iter(lambda: f.readinto(mv), 0):
|
||||
h.update(mv[:n])
|
||||
return h.hexdigest()
|
||||
|
|
@ -62,27 +64,32 @@ class RemoteFileHandler:
|
|||
return True
|
||||
if not hash_type:
|
||||
raise ValueError("Provide hash type, md5 or sha256")
|
||||
if hash_type == 'md5':
|
||||
if hash_type == "md5":
|
||||
return RemoteFileHandler.check_md5(fpath, hash_value)
|
||||
if hash_type == "sha256":
|
||||
return RemoteFileHandler.check_sha256(fpath, hash_value)
|
||||
|
||||
@staticmethod
|
||||
def download_url(
|
||||
url, root, filename=None,
|
||||
sha256=None, max_redirect_hops=3
|
||||
url,
|
||||
root,
|
||||
filename=None,
|
||||
max_redirect_hops=3,
|
||||
headers=None
|
||||
):
|
||||
"""Download a file from a url and place it in root.
|
||||
"""Download a file from url and place it in root.
|
||||
|
||||
Args:
|
||||
url (str): URL to download file from
|
||||
root (str): Directory to place downloaded file in
|
||||
filename (str, optional): Name to save the file under.
|
||||
If None, use the basename of the URL
|
||||
sha256 (str, optional): sha256 checksum of the download.
|
||||
If None, do not check
|
||||
max_redirect_hops (int, optional): Maximum number of redirect
|
||||
max_redirect_hops (Optional[int]): Maximum number of redirect
|
||||
hops allowed
|
||||
headers (Optional[dict[str, str]]): Additional required headers
|
||||
- Authentication etc..
|
||||
"""
|
||||
|
||||
root = os.path.expanduser(root)
|
||||
if not filename:
|
||||
filename = os.path.basename(url)
|
||||
|
|
@ -90,55 +97,44 @@ class RemoteFileHandler:
|
|||
|
||||
os.makedirs(root, exist_ok=True)
|
||||
|
||||
# check if file is already present locally
|
||||
if RemoteFileHandler.check_integrity(fpath,
|
||||
sha256, hash_type="sha256"):
|
||||
print('Using downloaded and verified file: ' + fpath)
|
||||
return
|
||||
|
||||
# expand redirect chain if needed
|
||||
url = RemoteFileHandler._get_redirect_url(url,
|
||||
max_hops=max_redirect_hops)
|
||||
url = RemoteFileHandler._get_redirect_url(
|
||||
url, max_hops=max_redirect_hops, headers=headers)
|
||||
|
||||
# check if file is located on Google Drive
|
||||
file_id = RemoteFileHandler._get_google_drive_file_id(url)
|
||||
if file_id is not None:
|
||||
return RemoteFileHandler.download_file_from_google_drive(
|
||||
file_id, root, filename, sha256)
|
||||
file_id, root, filename)
|
||||
|
||||
# download the file
|
||||
try:
|
||||
print('Downloading ' + url + ' to ' + fpath)
|
||||
RemoteFileHandler._urlretrieve(url, fpath)
|
||||
except (urllib.error.URLError, IOError) as e:
|
||||
if url[:5] == 'https':
|
||||
url = url.replace('https:', 'http:')
|
||||
print('Failed download. Trying https -> http instead.'
|
||||
' Downloading ' + url + ' to ' + fpath)
|
||||
RemoteFileHandler._urlretrieve(url, fpath)
|
||||
else:
|
||||
raise e
|
||||
print(f"Downloading {url} to {fpath}")
|
||||
RemoteFileHandler._urlretrieve(url, fpath, headers=headers)
|
||||
except (urllib.error.URLError, IOError) as exc:
|
||||
if url[:5] != "https":
|
||||
raise exc
|
||||
|
||||
# check integrity of downloaded file
|
||||
if not RemoteFileHandler.check_integrity(fpath,
|
||||
sha256, hash_type="sha256"):
|
||||
raise RuntimeError("File not found or corrupted.")
|
||||
url = url.replace("https:", "http:")
|
||||
print((
|
||||
"Failed download. Trying https -> http instead."
|
||||
f" Downloading {url} to {fpath}"
|
||||
))
|
||||
RemoteFileHandler._urlretrieve(url, fpath, headers=headers)
|
||||
|
||||
@staticmethod
|
||||
def download_file_from_google_drive(file_id, root,
|
||||
filename=None,
|
||||
sha256=None):
|
||||
def download_file_from_google_drive(
|
||||
file_id, root, filename=None
|
||||
):
|
||||
"""Download a Google Drive file from and place it in root.
|
||||
Args:
|
||||
file_id (str): id of file to be downloaded
|
||||
root (str): Directory to place downloaded file in
|
||||
filename (str, optional): Name to save the file under.
|
||||
If None, use the id of the file.
|
||||
sha256 (str, optional): sha256 checksum of the download.
|
||||
If None, do not check
|
||||
"""
|
||||
# Based on https://stackoverflow.com/questions/38511444/python-download-files-from-google-drive-using-url # noqa
|
||||
import requests
|
||||
|
||||
url = "https://docs.google.com/uc?export=download"
|
||||
|
||||
root = os.path.expanduser(root)
|
||||
|
|
@ -148,17 +144,16 @@ class RemoteFileHandler:
|
|||
|
||||
os.makedirs(root, exist_ok=True)
|
||||
|
||||
if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(
|
||||
fpath, sha256, hash_type="sha256"):
|
||||
print('Using downloaded and verified file: ' + fpath)
|
||||
if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(fpath):
|
||||
print(f"Using downloaded and verified file: {fpath}")
|
||||
else:
|
||||
session = requests.Session()
|
||||
|
||||
response = session.get(url, params={'id': file_id}, stream=True)
|
||||
response = session.get(url, params={"id": file_id}, stream=True)
|
||||
token = RemoteFileHandler._get_confirm_token(response)
|
||||
|
||||
if token:
|
||||
params = {'id': file_id, 'confirm': token}
|
||||
params = {"id": file_id, "confirm": token}
|
||||
response = session.get(url, params=params, stream=True)
|
||||
|
||||
response_content_generator = response.iter_content(32768)
|
||||
|
|
@ -186,28 +181,28 @@ class RemoteFileHandler:
|
|||
destination_path = os.path.dirname(path)
|
||||
|
||||
_, archive_type = os.path.splitext(path)
|
||||
archive_type = archive_type.lstrip('.')
|
||||
archive_type = archive_type.lstrip(".")
|
||||
|
||||
if archive_type in ['zip']:
|
||||
print("Unzipping {}->{}".format(path, destination_path))
|
||||
if archive_type in ["zip"]:
|
||||
print(f"Unzipping {path}->{destination_path}")
|
||||
zip_file = zipfile.ZipFile(path)
|
||||
zip_file.extractall(destination_path)
|
||||
zip_file.close()
|
||||
|
||||
elif archive_type in [
|
||||
'tar', 'tgz', 'tar.gz', 'tar.xz', 'tar.bz2'
|
||||
"tar", "tgz", "tar.gz", "tar.xz", "tar.bz2"
|
||||
]:
|
||||
print("Unzipping {}->{}".format(path, destination_path))
|
||||
if archive_type == 'tar':
|
||||
tar_type = 'r:'
|
||||
elif archive_type.endswith('xz'):
|
||||
tar_type = 'r:xz'
|
||||
elif archive_type.endswith('gz'):
|
||||
tar_type = 'r:gz'
|
||||
elif archive_type.endswith('bz2'):
|
||||
tar_type = 'r:bz2'
|
||||
print(f"Unzipping {path}->{destination_path}")
|
||||
if archive_type == "tar":
|
||||
tar_type = "r:"
|
||||
elif archive_type.endswith("xz"):
|
||||
tar_type = "r:xz"
|
||||
elif archive_type.endswith("gz"):
|
||||
tar_type = "r:gz"
|
||||
elif archive_type.endswith("bz2"):
|
||||
tar_type = "r:bz2"
|
||||
else:
|
||||
tar_type = 'r:*'
|
||||
tar_type = "r:*"
|
||||
try:
|
||||
tar_file = tarfile.open(path, tar_type)
|
||||
except tarfile.ReadError:
|
||||
|
|
@ -216,29 +211,35 @@ class RemoteFileHandler:
|
|||
tar_file.close()
|
||||
|
||||
@staticmethod
|
||||
def _urlretrieve(url, filename, chunk_size):
|
||||
def _urlretrieve(url, filename, chunk_size=None, headers=None):
|
||||
final_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
final_headers.update(headers)
|
||||
|
||||
chunk_size = chunk_size or 8192
|
||||
with open(filename, "wb") as fh:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url,
|
||||
headers={"User-Agent": USER_AGENT})) \
|
||||
as response:
|
||||
urllib.request.Request(url, headers=final_headers)
|
||||
) as response:
|
||||
for chunk in iter(lambda: response.read(chunk_size), ""):
|
||||
if not chunk:
|
||||
break
|
||||
fh.write(chunk)
|
||||
|
||||
@staticmethod
|
||||
def _get_redirect_url(url, max_hops):
|
||||
def _get_redirect_url(url, max_hops, headers=None):
|
||||
initial_url = url
|
||||
headers = {"Method": "HEAD", "User-Agent": USER_AGENT}
|
||||
|
||||
final_headers = {"Method": "HEAD", "User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
final_headers.update(headers)
|
||||
for _ in range(max_hops + 1):
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, headers=headers)) as response:
|
||||
urllib.request.Request(url, headers=final_headers)
|
||||
) as response:
|
||||
if response.url == url or response.url is None:
|
||||
return url
|
||||
|
||||
url = response.url
|
||||
return response.url
|
||||
else:
|
||||
raise RecursionError(
|
||||
f"Request to {initial_url} exceeded {max_hops} redirects. "
|
||||
|
|
@ -248,7 +249,7 @@ class RemoteFileHandler:
|
|||
@staticmethod
|
||||
def _get_confirm_token(response):
|
||||
for key, value in response.cookies.items():
|
||||
if key.startswith('download_warning'):
|
||||
if key.startswith("download_warning"):
|
||||
return value
|
||||
|
||||
# handle antivirus warning for big zips
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
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")
|
||||
146
common/ayon_common/distribution/ui/missing_bundle_window.py
Normal file
146
common/ayon_common/distribution/ui/missing_bundle_window.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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" <b>{self._url}</b>" if self._url else ""
|
||||
|
||||
if self._bundle_name:
|
||||
return (
|
||||
f"Requested release bundle <b>{self._bundle_name}</b>"
|
||||
f" is not available on server{url_part}."
|
||||
"<br/><br/>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."
|
||||
"<br/><br/>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 <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()
|
||||
90
common/ayon_common/distribution/utils.py
Normal file
90
common/ayon_common/distribution/utils.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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)
|
||||
BIN
common/ayon_common/resources/AYON.icns
Normal file
BIN
common/ayon_common/resources/AYON.icns
Normal file
Binary file not shown.
BIN
common/ayon_common/resources/AYON.ico
Normal file
BIN
common/ayon_common/resources/AYON.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
common/ayon_common/resources/AYON.png
Normal file
BIN
common/ayon_common/resources/AYON.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
common/ayon_common/resources/AYON_staging.png
Normal file
BIN
common/ayon_common/resources/AYON_staging.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
25
common/ayon_common/resources/__init__.py
Normal file
25
common/ayon_common/resources/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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
|
||||
BIN
common/ayon_common/resources/edit.png
Normal file
BIN
common/ayon_common/resources/edit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
BIN
common/ayon_common/resources/eye.png
Normal file
BIN
common/ayon_common/resources/eye.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
84
common/ayon_common/resources/stylesheet.css
Normal file
84
common/ayon_common/resources/stylesheet.css
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
* {
|
||||
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;
|
||||
}
|
||||
36
common/ayon_common/ui_utils.py
Normal file
36
common/ayon_common/ui_utils.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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)
|
||||
90
common/ayon_common/utils.py
Normal file
90
common/ayon_common/utils.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import os
|
||||
from enum import Enum
|
||||
from abc import abstractmethod
|
||||
import attr
|
||||
import logging
|
||||
import requests
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
from .file_handler import RemoteFileHandler
|
||||
from .addon_info import AddonInfo
|
||||
|
||||
|
||||
class UpdateState(Enum):
|
||||
EXISTS = "exists"
|
||||
UPDATED = "updated"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class AddonDownloader:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def __init__(self):
|
||||
self._downloaders = {}
|
||||
|
||||
def register_format(self, downloader_type, downloader):
|
||||
self._downloaders[downloader_type.value] = downloader
|
||||
|
||||
def get_downloader(self, downloader_type):
|
||||
downloader = self._downloaders.get(downloader_type)
|
||||
if not downloader:
|
||||
raise ValueError(f"{downloader_type} not implemented")
|
||||
return downloader()
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def download(cls, source, destination):
|
||||
"""Returns url to downloaded addon zip file.
|
||||
|
||||
Args:
|
||||
source (dict): {type:"http", "url":"https://} ...}
|
||||
destination (str): local folder to unzip
|
||||
Returns:
|
||||
(str) local path to addon zip file
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def check_hash(cls, addon_path, addon_hash):
|
||||
"""Compares 'hash' of downloaded 'addon_url' file.
|
||||
|
||||
Args:
|
||||
addon_path (str): local path to addon zip file
|
||||
addon_hash (str): sha256 hash of zip file
|
||||
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="sha256"):
|
||||
raise ValueError(f"{addon_path} doesn't match expected hash.")
|
||||
|
||||
@classmethod
|
||||
def unzip(cls, addon_zip_path, destination):
|
||||
"""Unzips local 'addon_zip_path' to 'destination'.
|
||||
|
||||
Args:
|
||||
addon_zip_path (str): local path to addon zip file
|
||||
destination (str): local folder to unzip
|
||||
"""
|
||||
RemoteFileHandler.unzip(addon_zip_path, destination)
|
||||
os.remove(addon_zip_path)
|
||||
|
||||
@classmethod
|
||||
def remove(cls, addon_url):
|
||||
pass
|
||||
|
||||
|
||||
class OSAddonDownloader(AddonDownloader):
|
||||
|
||||
@classmethod
|
||||
def download(cls, source, destination):
|
||||
# OS doesnt need to download, unzip directly
|
||||
addon_url = source["path"].get(platform.system().lower())
|
||||
if not os.path.exists(addon_url):
|
||||
raise ValueError("{} is not accessible".format(addon_url))
|
||||
return addon_url
|
||||
|
||||
|
||||
class HTTPAddonDownloader(AddonDownloader):
|
||||
CHUNK_SIZE = 100000
|
||||
|
||||
@classmethod
|
||||
def download(cls, source, destination):
|
||||
source_url = source["url"]
|
||||
cls.log.debug(f"Downloading {source_url} to {destination}")
|
||||
file_name = os.path.basename(destination)
|
||||
_, ext = os.path.splitext(file_name)
|
||||
if (ext.replace(".", '') not
|
||||
in set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS)):
|
||||
file_name += ".zip"
|
||||
RemoteFileHandler.download_url(source_url,
|
||||
destination,
|
||||
filename=file_name)
|
||||
|
||||
return os.path.join(destination, file_name)
|
||||
|
||||
|
||||
def get_addons_info(server_endpoint):
|
||||
"""Returns list of addon information from Server"""
|
||||
# TODO temp
|
||||
# addon_info = AddonInfo(
|
||||
# **{"name": "openpype_slack",
|
||||
# "version": "1.0.0",
|
||||
# "addon_url": "c:/projects/openpype_slack_1.0.0.zip",
|
||||
# "type": UrlType.FILESYSTEM,
|
||||
# "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658"}) # noqa
|
||||
#
|
||||
# http_addon = AddonInfo(
|
||||
# **{"name": "openpype_slack",
|
||||
# "version": "1.0.0",
|
||||
# "addon_url": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing", # noqa
|
||||
# "type": UrlType.HTTP,
|
||||
# "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658"}) # noqa
|
||||
|
||||
response = requests.get(server_endpoint)
|
||||
if not response.ok:
|
||||
raise Exception(response.text)
|
||||
|
||||
addons_info = []
|
||||
for addon in response.json():
|
||||
addons_info.append(AddonInfo(**addon))
|
||||
return addons_info
|
||||
|
||||
|
||||
def update_addon_state(addon_infos, destination_folder, factory,
|
||||
log=None):
|
||||
"""Loops through all 'addon_infos', compares local version, unzips.
|
||||
|
||||
Loops through server provided list of dictionaries with information about
|
||||
available addons. Looks if each addon is already present and deployed.
|
||||
If isn't, addon zip gets downloaded and unzipped into 'destination_folder'.
|
||||
Args:
|
||||
addon_infos (list of AddonInfo)
|
||||
destination_folder (str): local path
|
||||
factory (AddonDownloader): factory to get appropriate downloader per
|
||||
addon type
|
||||
log (logging.Logger)
|
||||
Returns:
|
||||
(dict): {"addon_full_name": UpdateState.value
|
||||
(eg. "exists"|"updated"|"failed")
|
||||
"""
|
||||
if not log:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
download_states = {}
|
||||
for addon in addon_infos:
|
||||
full_name = "{}_{}".format(addon.name, addon.version)
|
||||
addon_dest = os.path.join(destination_folder, full_name)
|
||||
|
||||
if os.path.isdir(addon_dest):
|
||||
log.debug(f"Addon version folder {addon_dest} already exists.")
|
||||
download_states[full_name] = UpdateState.EXISTS.value
|
||||
continue
|
||||
|
||||
for source in addon.sources:
|
||||
download_states[full_name] = UpdateState.FAILED.value
|
||||
try:
|
||||
downloader = factory.get_downloader(source.type)
|
||||
zip_file_path = downloader.download(attr.asdict(source),
|
||||
addon_dest)
|
||||
downloader.check_hash(zip_file_path, addon.hash)
|
||||
downloader.unzip(zip_file_path, addon_dest)
|
||||
download_states[full_name] = UpdateState.UPDATED.value
|
||||
break
|
||||
except Exception:
|
||||
log.warning(f"Error happened during updating {addon.name}",
|
||||
exc_info=True)
|
||||
if os.path.isdir(addon_dest):
|
||||
log.debug(f"Cleaning {addon_dest}")
|
||||
shutil.rmtree(addon_dest)
|
||||
|
||||
return download_states
|
||||
|
||||
|
||||
def check_addons(server_endpoint, addon_folder, downloaders):
|
||||
"""Main entry point to compare existing addons with those on server.
|
||||
|
||||
Args:
|
||||
server_endpoint (str): url to v4 server endpoint
|
||||
addon_folder (str): local dir path for addons
|
||||
downloaders (AddonDownloader): factory of downloaders
|
||||
|
||||
Raises:
|
||||
(RuntimeError) if any addon failed update
|
||||
"""
|
||||
addons_info = get_addons_info(server_endpoint)
|
||||
result = update_addon_state(addons_info,
|
||||
addon_folder,
|
||||
downloaders)
|
||||
if UpdateState.FAILED.value in result.values():
|
||||
raise RuntimeError(f"Unable to update some addons {result}")
|
||||
|
||||
|
||||
def cli(*args):
|
||||
raise NotImplementedError
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import attr
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UrlType(Enum):
|
||||
HTTP = "http"
|
||||
GIT = "git"
|
||||
FILESYSTEM = "filesystem"
|
||||
|
||||
|
||||
@attr.s
|
||||
class MultiPlatformPath(object):
|
||||
windows = attr.ib(default=None)
|
||||
linux = attr.ib(default=None)
|
||||
darwin = attr.ib(default=None)
|
||||
|
||||
|
||||
@attr.s
|
||||
class AddonSource(object):
|
||||
type = attr.ib()
|
||||
|
||||
|
||||
@attr.s
|
||||
class LocalAddonSource(AddonSource):
|
||||
path = attr.ib(default=attr.Factory(MultiPlatformPath))
|
||||
|
||||
|
||||
@attr.s
|
||||
class WebAddonSource(AddonSource):
|
||||
url = attr.ib(default=None)
|
||||
|
||||
|
||||
@attr.s
|
||||
class VersionData(object):
|
||||
version_data = attr.ib(default=None)
|
||||
|
||||
|
||||
@attr.s
|
||||
class AddonInfo(object):
|
||||
"""Object matching json payload from Server"""
|
||||
name = attr.ib()
|
||||
version = attr.ib()
|
||||
title = attr.ib(default=None)
|
||||
sources = attr.ib(default=attr.Factory(dict))
|
||||
hash = 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):
|
||||
sources = []
|
||||
|
||||
production_version = data.get("productionVersion")
|
||||
if not production_version:
|
||||
return
|
||||
|
||||
# server payload contains info about all versions
|
||||
# active addon must have 'productionVersion' and matching version info
|
||||
version_data = data.get("versions", {})[production_version]
|
||||
|
||||
for source in version_data.get("clientSourceInfo", []):
|
||||
if source.get("type") == UrlType.FILESYSTEM.value:
|
||||
source_addon = LocalAddonSource(type=source["type"],
|
||||
path=source["path"])
|
||||
if source.get("type") == UrlType.HTTP.value:
|
||||
source_addon = WebAddonSource(type=source["type"],
|
||||
url=source["url"])
|
||||
|
||||
sources.append(source_addon)
|
||||
|
||||
return cls(name=data.get("name"),
|
||||
version=production_version,
|
||||
sources=sources,
|
||||
hash=data.get("hash"),
|
||||
description=data.get("description"),
|
||||
title=data.get("title"),
|
||||
license=data.get("license"),
|
||||
authors=data.get("authors"))
|
||||
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import pytest
|
||||
import attr
|
||||
import tempfile
|
||||
|
||||
from common.openpype_common.distribution.addon_distribution import (
|
||||
AddonDownloader,
|
||||
OSAddonDownloader,
|
||||
HTTPAddonDownloader,
|
||||
AddonInfo,
|
||||
update_addon_state,
|
||||
UpdateState
|
||||
)
|
||||
from common.openpype_common.distribution.addon_info import UrlType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def addon_downloader():
|
||||
addon_downloader = AddonDownloader()
|
||||
addon_downloader.register_format(UrlType.FILESYSTEM, OSAddonDownloader)
|
||||
addon_downloader.register_format(UrlType.HTTP, HTTPAddonDownloader)
|
||||
|
||||
yield addon_downloader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_downloader(addon_downloader):
|
||||
yield addon_downloader.get_downloader(UrlType.HTTP.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_folder():
|
||||
yield tempfile.mkdtemp()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_addon_info():
|
||||
addon_info = {
|
||||
"versions": {
|
||||
"1.0.0": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hasSettings": True,
|
||||
"clientSourceInfo": [
|
||||
{
|
||||
"type": "http",
|
||||
"url": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing" # noqa
|
||||
},
|
||||
{
|
||||
"type": "filesystem",
|
||||
"path": {
|
||||
"windows": ["P:/sources/some_file.zip",
|
||||
"W:/sources/some_file.zip"], # noqa
|
||||
"linux": ["/mnt/srv/sources/some_file.zip"],
|
||||
"darwin": ["/Volumes/srv/sources/some_file.zip"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"frontendScopes": {
|
||||
"project": {
|
||||
"sidebar": "hierarchy"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"title": "Slack addon",
|
||||
"name": "openpype_slack",
|
||||
"productionVersion": "1.0.0",
|
||||
"hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658" # noqa
|
||||
}
|
||||
yield addon_info
|
||||
|
||||
|
||||
def test_register(printer):
|
||||
addon_downloader = AddonDownloader()
|
||||
|
||||
assert len(addon_downloader._downloaders) == 0, "Contains registered"
|
||||
|
||||
addon_downloader.register_format(UrlType.FILESYSTEM, OSAddonDownloader)
|
||||
assert len(addon_downloader._downloaders) == 1, "Should contain one"
|
||||
|
||||
|
||||
def test_get_downloader(printer, addon_downloader):
|
||||
assert addon_downloader.get_downloader(UrlType.FILESYSTEM.value), "Should find" # noqa
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
addon_downloader.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": "openpype_slack",
|
||||
"productionVersion": "1.0.0",
|
||||
"versions": {
|
||||
"1.0.0": {
|
||||
"clientSourceInfo": [
|
||||
{
|
||||
"type": "filesystem",
|
||||
"path": {
|
||||
"windows": [
|
||||
"P:/sources/some_file.zip",
|
||||
"W:/sources/some_file.zip"],
|
||||
"linux": [
|
||||
"/mnt/srv/sources/some_file.zip"],
|
||||
"darwin": [
|
||||
"/Volumes/srv/sources/some_file.zip"] # noqa
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert AddonInfo.from_dict(valid_minimum), "Missing required fields"
|
||||
|
||||
valid_minimum["versions"].pop("1.0.0")
|
||||
with pytest.raises(KeyError):
|
||||
assert not AddonInfo.from_dict(valid_minimum), "Must fail without version data" # noqa
|
||||
|
||||
valid_minimum.pop("productionVersion")
|
||||
assert not AddonInfo.from_dict(
|
||||
valid_minimum), "none if not productionVersion" # noqa
|
||||
|
||||
addon = AddonInfo.from_dict(sample_addon_info)
|
||||
assert addon, "Should be created"
|
||||
assert addon.name == "openpype_slack", "Incorrect name"
|
||||
assert addon.version == "1.0.0", "Incorrect version"
|
||||
|
||||
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 test_update_addon_state(printer, sample_addon_info,
|
||||
temp_folder, addon_downloader):
|
||||
"""Tests possible cases of addon update."""
|
||||
addon_info = AddonInfo.from_dict(sample_addon_info)
|
||||
orig_hash = addon_info.hash
|
||||
|
||||
addon_info.hash = "brokenhash"
|
||||
result = update_addon_state([addon_info], temp_folder, addon_downloader)
|
||||
assert result["openpype_slack_1.0.0"] == UpdateState.FAILED.value, \
|
||||
"Update should failed because of wrong hash"
|
||||
|
||||
addon_info.hash = orig_hash
|
||||
result = update_addon_state([addon_info], temp_folder, addon_downloader)
|
||||
assert result["openpype_slack_1.0.0"] == UpdateState.UPDATED.value, \
|
||||
"Addon should have been updated"
|
||||
|
||||
result = update_addon_state([addon_info], temp_folder, addon_downloader)
|
||||
assert result["openpype_slack_1.0.0"] == UpdateState.EXISTS.value, \
|
||||
"Addon should already exist"
|
||||
74
docs/README.md
Normal file
74
docs/README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
API Documentation
|
||||
=================
|
||||
|
||||
This documents the way how to build and modify API documentation using Sphinx and AutoAPI. Ground for documentation
|
||||
should be directly in sources - in docstrings and markdowns. Sphinx and AutoAPI will crawl over them and generate
|
||||
RST files that are in turn used to generate HTML documentation. For docstrings we prefer "Napoleon" or "Google" style
|
||||
docstrings, but RST is also acceptable mainly in cases where you need to use Sphinx directives.
|
||||
|
||||
Using only docstrings is not really viable as some documentation should be done on higher level - like overview of
|
||||
some modules/functionality and so on. This should be done directly in RST files and committed to repository.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
Configuration is done in `/docs/source/conf.py`. The most important settings are:
|
||||
|
||||
- `autodoc_mock_imports`: add modules that can't be actually imported by Sphinx in running environment, like `nuke`, `maya`, etc.
|
||||
- `autoapi_ignore`: add directories that shouldn't be processed by **AutoAPI**, like vendor dirs, etc.
|
||||
- `html_theme_options`: you can use these options to influence how the html theme of the generated files will look.
|
||||
- `myst_gfm_only`: are Myst parser option for Markdown setting what flavour of Markdown should be used.
|
||||
|
||||
How to build it
|
||||
---------------
|
||||
|
||||
You can run:
|
||||
|
||||
```sh
|
||||
cd .\docs
|
||||
make.bat html
|
||||
```
|
||||
|
||||
on linux/macOS:
|
||||
|
||||
```sh
|
||||
cd ./docs
|
||||
make html
|
||||
```
|
||||
|
||||
This will go over our code and generate **.rst** files in `/docs/source/autoapi` and from those it will generate
|
||||
full html documentation in `/docs/build/html`.
|
||||
|
||||
During the build you may see tons of red errors that are pointing to our issues:
|
||||
|
||||
1) **Wrong imports** -
|
||||
Invalid import are usually wrong relative imports (too deep) or circular imports.
|
||||
2) **Invalid docstrings** -
|
||||
Docstrings 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** -
|
||||
Markdown/RST files can be included inside RST files using `.. include::` directive. But they have to be properly
|
||||
formatted.
|
||||
|
||||
Editing RST templates
|
||||
---------------------
|
||||
Everything starts with `/docs/source/index.rst` - this file should be properly edited, Right now it just
|
||||
includes `readme.rst` that in turn include and parse main `README.md`. This is entrypoint to API documentation.
|
||||
All templates generated by AutoAPI are in `/docs/source/autoapi`. They should be eventually committed to repository
|
||||
and edited too.
|
||||
|
||||
Steps for enhancing API documentation
|
||||
-------------------------------------
|
||||
|
||||
1) Run `/docs/make.bat html`
|
||||
2) Read the red errors/warnings - fix it in the code
|
||||
3) Run `/docs/make.bat html` - again until there are no red lines
|
||||
4) Edit RST files and add some meaningful content there
|
||||
|
||||
Resources
|
||||
=========
|
||||
|
||||
- [ReStructuredText on Wikipedia](https://en.wikipedia.org/wiki/ReStructuredText)
|
||||
- [RST Quick Reference](https://docutils.sourceforge.io/docs/user/rst/quickref.html)
|
||||
- [Sphinx AutoAPI Documentation](https://sphinx-autoapi.readthedocs.io/en/latest/)
|
||||
- [Example of Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html)
|
||||
- [Sphinx Directives](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html)
|
||||
|
|
@ -5,7 +5,7 @@ pushd %~dp0
|
|||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
set SPHINXBUILD=..\.poetry\bin\poetry run sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
|
|
|||
38
docs/source/_static/AYON_tight_G.svg
Normal file
38
docs/source/_static/AYON_tight_G.svg
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1801 501" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-13243,-17814)">
|
||||
<g id="AYON_tight_G" transform="matrix(0.736439,0,0,0.560717,-6190.22,8134.09)">
|
||||
<rect x="26388.3" y="17264.3" width="2444.2" height="891.715" style="fill:none;"/>
|
||||
<g id="AYON_logo" transform="matrix(5.32251,0,0,6.99052,25370,15936.6)">
|
||||
<g transform="matrix(1,0,0,1,471.969,279.213)">
|
||||
<path d="M0,-34.016C9.378,-34.016 17.008,-26.386 17.008,-17.008C17.008,-7.63 9.378,0 0,0C-9.378,0 -17.008,-7.63 -17.008,-17.008C-17.008,-26.386 -9.378,-34.016 0,-34.016M0,-68.032C-28.18,-68.032 -51.024,-45.188 -51.024,-17.008C-51.024,11.172 -28.18,34.016 0,34.016C28.18,34.016 51.024,11.172 51.024,-17.008C51.024,-45.188 28.18,-68.032 0,-68.032" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,293.386,211.343)">
|
||||
<path d="M0,101.886C-4.696,101.886 -8.504,98.078 -8.504,93.382L-8.504,28.874L-79.027,99.395C-82.349,102.716 -87.73,102.716 -91.052,99.395C-94.374,96.075 -94.374,90.689 -91.052,87.369L-6.012,2.33C-3.583,-0.103 0.071,-0.83 3.255,0.487C6.432,1.803 8.504,4.902 8.504,8.343L8.504,93.382C8.504,98.078 4.696,101.886 0,101.886" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,548.504,211.343)">
|
||||
<path d="M0,101.886C-4.696,101.886 -8.504,98.078 -8.504,93.382L-8.504,8.343C-8.504,4.902 -6.432,1.803 -3.255,0.487C-0.075,-0.83 3.579,-0.103 6.013,2.33L91.052,87.369C94.374,90.689 94.374,96.075 91.052,99.395C87.73,102.716 82.349,102.716 79.027,99.395L8.504,28.874L8.504,93.382C8.504,98.078 4.696,101.886 0,101.886" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,633.543,211.181)">
|
||||
<path d="M0,68.032C-4.696,68.032 -8.504,64.224 -8.504,59.528L-8.504,8.504C-8.504,3.808 -4.696,0 0,0C4.696,0 8.504,3.808 8.504,8.504L8.504,59.528C8.504,64.224 4.696,68.032 0,68.032" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(-1,0,0,1,654.804,-155.906)">
|
||||
<rect x="318.898" y="367.087" width="17.008" height="17.008" style="fill:rgb(0,214,161);"/>
|
||||
</g>
|
||||
<g transform="matrix(-1,0,0,1,688.82,-121.89)">
|
||||
<rect x="335.906" y="350.079" width="17.008" height="17.008" style="fill:rgb(0,214,161);"/>
|
||||
</g>
|
||||
<g transform="matrix(0,-1,-1,0,361.417,270.709)">
|
||||
<path d="M-8.504,-8.504C-13.2,-8.504 -17.008,-4.697 -17.008,0C-17.008,4.697 -13.2,8.504 -8.504,8.504C-3.807,8.504 0,4.697 0,0C0,-4.697 -3.807,-8.504 -8.504,-8.504" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0,-1,-1,0,361.417,296.221)">
|
||||
<path d="M-8.504,-8.504C-13.201,-8.504 -17.008,-4.697 -17.008,0C-17.008,4.697 -13.201,8.504 -8.504,8.504C-3.807,8.504 0,4.697 0,0C0,-4.697 -3.807,-8.504 -8.504,-8.504" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,403.937,262.205)">
|
||||
<path d="M0,-68.031L-51.023,-17.008L-51.023,0L-34.016,0L17.008,-51.023L17.008,-68.031L0,-68.031Z" style="fill:rgb(0,214,161);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
15
docs/source/_templates/autoapi/index.rst
Normal file
15
docs/source/_templates/autoapi/index.rst
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
API Reference
|
||||
=============
|
||||
|
||||
This page contains auto-generated API reference documentation [#f1]_.
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
|
||||
{% for page in pages %}
|
||||
{% if page.top_level_object and page.display %}
|
||||
{{ page.include_path }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
.. [#f1] Created with `sphinx-autoapi <https://github.com/readthedocs/sphinx-autoapi>`_
|
||||
1
docs/source/_templates/autoapi/python/attribute.rst
Normal file
1
docs/source/_templates/autoapi/python/attribute.rst
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "python/data.rst" %}
|
||||
58
docs/source/_templates/autoapi/python/class.rst
Normal file
58
docs/source/_templates/autoapi/python/class.rst
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{% if obj.display %}
|
||||
.. py:{{ obj.type }}:: {{ obj.short_name }}{% if obj.args %}({{ obj.args }}){% endif %}
|
||||
{% for (args, return_annotation) in obj.overloads %}
|
||||
{{ " " * (obj.type | length) }} {{ obj.short_name }}{% if args %}({{ args }}){% endif %}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% if obj.bases %}
|
||||
{% if "show-inheritance" in autoapi_options %}
|
||||
Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if "show-inheritance-diagram" in autoapi_options and obj.bases != ["object"] %}
|
||||
.. autoapi-inheritance-diagram:: {{ obj.obj["full_name"] }}
|
||||
:parts: 1
|
||||
{% if "private-members" in autoapi_options %}
|
||||
:private-bases:
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if obj.docstring %}
|
||||
{{ obj.docstring|indent(3) }}
|
||||
{% endif %}
|
||||
{% if "inherited-members" in autoapi_options %}
|
||||
{% set visible_classes = obj.classes|selectattr("display")|list %}
|
||||
{% else %}
|
||||
{% set visible_classes = obj.classes|rejectattr("inherited")|selectattr("display")|list %}
|
||||
{% endif %}
|
||||
{% for klass in visible_classes %}
|
||||
{{ klass.render()|indent(3) }}
|
||||
{% endfor %}
|
||||
{% if "inherited-members" in autoapi_options %}
|
||||
{% set visible_properties = obj.properties|selectattr("display")|list %}
|
||||
{% else %}
|
||||
{% set visible_properties = obj.properties|rejectattr("inherited")|selectattr("display")|list %}
|
||||
{% endif %}
|
||||
{% for property in visible_properties %}
|
||||
{{ property.render()|indent(3) }}
|
||||
{% endfor %}
|
||||
{% if "inherited-members" in autoapi_options %}
|
||||
{% set visible_attributes = obj.attributes|selectattr("display")|list %}
|
||||
{% else %}
|
||||
{% set visible_attributes = obj.attributes|rejectattr("inherited")|selectattr("display")|list %}
|
||||
{% endif %}
|
||||
{% for attribute in visible_attributes %}
|
||||
{{ attribute.render()|indent(3) }}
|
||||
{% endfor %}
|
||||
{% if "inherited-members" in autoapi_options %}
|
||||
{% set visible_methods = obj.methods|selectattr("display")|list %}
|
||||
{% else %}
|
||||
{% set visible_methods = obj.methods|rejectattr("inherited")|selectattr("display")|list %}
|
||||
{% endif %}
|
||||
{% for method in visible_methods %}
|
||||
{{ method.render()|indent(3) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
37
docs/source/_templates/autoapi/python/data.rst
Normal file
37
docs/source/_templates/autoapi/python/data.rst
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{% if obj.display %}
|
||||
.. py:{{ obj.type }}:: {{ obj.name }}
|
||||
{%- if obj.annotation is not none %}
|
||||
|
||||
:type: {%- if obj.annotation %} {{ obj.annotation }}{%- endif %}
|
||||
|
||||
{%- endif %}
|
||||
|
||||
{%- if obj.value is not none %}
|
||||
|
||||
:value: {% if obj.value is string and obj.value.splitlines()|count > 1 -%}
|
||||
Multiline-String
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<details><summary>Show Value</summary>
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
"""{{ obj.value|indent(width=8,blank=true) }}"""
|
||||
|
||||
.. raw:: html
|
||||
|
||||
</details>
|
||||
|
||||
{%- else -%}
|
||||
{%- if obj.value is string -%}
|
||||
{{ "%r" % obj.value|string|truncate(100) }}
|
||||
{%- else -%}
|
||||
{{ obj.value|string|truncate(100) }}
|
||||
{%- endif -%}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
|
||||
|
||||
{{ obj.docstring|indent(3) }}
|
||||
{% endif %}
|
||||
1
docs/source/_templates/autoapi/python/exception.rst
Normal file
1
docs/source/_templates/autoapi/python/exception.rst
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "python/class.rst" %}
|
||||
15
docs/source/_templates/autoapi/python/function.rst
Normal file
15
docs/source/_templates/autoapi/python/function.rst
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% if obj.display %}
|
||||
.. py:function:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %}
|
||||
|
||||
{% for (args, return_annotation) in obj.overloads %}
|
||||
{{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% for property in obj.properties %}
|
||||
:{{ property }}:
|
||||
{% endfor %}
|
||||
|
||||
{% if obj.docstring %}
|
||||
{{ obj.docstring|indent(3) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
19
docs/source/_templates/autoapi/python/method.rst
Normal file
19
docs/source/_templates/autoapi/python/method.rst
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{%- if obj.display %}
|
||||
.. py:method:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %}
|
||||
|
||||
{% for (args, return_annotation) in obj.overloads %}
|
||||
{{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% if obj.properties %}
|
||||
{% for property in obj.properties %}
|
||||
:{{ property }}:
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{% endif %}
|
||||
{% if obj.docstring %}
|
||||
{{ obj.docstring|indent(3) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
114
docs/source/_templates/autoapi/python/module.rst
Normal file
114
docs/source/_templates/autoapi/python/module.rst
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
{% if not obj.display %}
|
||||
:orphan:
|
||||
|
||||
{% endif %}
|
||||
:py:mod:`{{ obj.name }}`
|
||||
=========={{ "=" * obj.name|length }}
|
||||
|
||||
.. py:module:: {{ obj.name }}
|
||||
|
||||
{% if obj.docstring %}
|
||||
.. autoapi-nested-parse::
|
||||
|
||||
{{ obj.docstring|indent(3) }}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block subpackages %}
|
||||
{% set visible_subpackages = obj.subpackages|selectattr("display")|list %}
|
||||
{% if visible_subpackages %}
|
||||
Subpackages
|
||||
-----------
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 3
|
||||
|
||||
{% for subpackage in visible_subpackages %}
|
||||
{{ subpackage.short_name }}/index.rst
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block submodules %}
|
||||
{% set visible_submodules = obj.submodules|selectattr("display")|list %}
|
||||
{% if visible_submodules %}
|
||||
Submodules
|
||||
----------
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 1
|
||||
|
||||
{% for submodule in visible_submodules %}
|
||||
{{ submodule.short_name }}/index.rst
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if obj.all is not none %}
|
||||
{% set visible_children = obj.children|selectattr("short_name", "in", obj.all)|list %}
|
||||
{% elif obj.type is equalto("package") %}
|
||||
{% set visible_children = obj.children|selectattr("display")|list %}
|
||||
{% else %}
|
||||
{% set visible_children = obj.children|selectattr("display")|rejectattr("imported")|list %}
|
||||
{% endif %}
|
||||
{% if visible_children %}
|
||||
{{ obj.type|title }} Contents
|
||||
{{ "-" * obj.type|length }}---------
|
||||
|
||||
{% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %}
|
||||
{% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %}
|
||||
{% set visible_attributes = visible_children|selectattr("type", "equalto", "data")|list %}
|
||||
{% if "show-module-summary" in autoapi_options and (visible_classes or visible_functions) %}
|
||||
{% block classes scoped %}
|
||||
{% if visible_classes %}
|
||||
Classes
|
||||
~~~~~~~
|
||||
|
||||
.. autoapisummary::
|
||||
|
||||
{% for klass in visible_classes %}
|
||||
{{ klass.id }}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block functions scoped %}
|
||||
{% if visible_functions %}
|
||||
Functions
|
||||
~~~~~~~~~
|
||||
|
||||
.. autoapisummary::
|
||||
|
||||
{% for function in visible_functions %}
|
||||
{{ function.id }}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block attributes scoped %}
|
||||
{% if visible_attributes %}
|
||||
Attributes
|
||||
~~~~~~~~~~
|
||||
|
||||
.. autoapisummary::
|
||||
|
||||
{% for attribute in visible_attributes %}
|
||||
{{ attribute.id }}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
{% for obj_item in visible_children %}
|
||||
{{ obj_item.render()|indent(0) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
1
docs/source/_templates/autoapi/python/package.rst
Normal file
1
docs/source/_templates/autoapi/python/package.rst
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "python/module.rst" %}
|
||||
15
docs/source/_templates/autoapi/python/property.rst
Normal file
15
docs/source/_templates/autoapi/python/property.rst
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{%- if obj.display %}
|
||||
.. py:property:: {{ obj.short_name }}
|
||||
{% if obj.annotation %}
|
||||
:type: {{ obj.annotation }}
|
||||
{% endif %}
|
||||
{% if obj.properties %}
|
||||
{% for property in obj.properties %}
|
||||
:{{ property }}:
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if obj.docstring %}
|
||||
{{ obj.docstring|indent(3) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
@ -17,18 +17,29 @@
|
|||
|
||||
import os
|
||||
import sys
|
||||
pype_root = os.path.abspath('../..')
|
||||
sys.path.insert(0, pype_root)
|
||||
import revitron_sphinx_theme
|
||||
|
||||
openpype_root = os.path.abspath('../..')
|
||||
sys.path.insert(0, openpype_root)
|
||||
# app = QApplication([])
|
||||
|
||||
"""
|
||||
repos = os.listdir(os.path.abspath("../../repos"))
|
||||
repos = [os.path.join(pype_root, "repos", repo) for repo in repos]
|
||||
repos = [os.path.join(openpype_root, "repos", repo) for repo in repos]
|
||||
for repo in repos:
|
||||
sys.path.append(repo)
|
||||
"""
|
||||
|
||||
todo_include_todos = True
|
||||
autodoc_mock_imports = ["maya", "pymel", "nuke", "nukestudio", "nukescripts",
|
||||
"hiero", "bpy", "fusion", "houdini", "hou", "unreal",
|
||||
"__builtin__", "resolve", "pysync", "DaVinciResolveScript"]
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'pype'
|
||||
copyright = '2019, Orbi Tools'
|
||||
author = 'Orbi Tools'
|
||||
project = 'OpenPype'
|
||||
copyright = '2023 Ynput'
|
||||
author = 'Ynput'
|
||||
|
||||
# The short X.Y version
|
||||
version = ''
|
||||
|
|
@ -52,11 +63,41 @@ extensions = [
|
|||
'sphinx.ext.todo',
|
||||
'sphinx.ext.coverage',
|
||||
'sphinx.ext.mathjax',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinx.ext.autosummary',
|
||||
'recommonmark'
|
||||
'revitron_sphinx_theme',
|
||||
'autoapi.extension',
|
||||
'myst_parser'
|
||||
]
|
||||
|
||||
##############################
|
||||
# Autoapi settings
|
||||
##############################
|
||||
|
||||
autoapi_dirs = ['../../openpype', '../../igniter']
|
||||
|
||||
# bypass modules with a lot of python2 content for now
|
||||
autoapi_ignore = [
|
||||
"*vendor*",
|
||||
"*schemas*",
|
||||
"*startup/*",
|
||||
"*/website*",
|
||||
"*openpype/hooks*",
|
||||
"*openpype/style*",
|
||||
"openpype/tests*",
|
||||
# to many levels of relative import:
|
||||
"*/modules/sync_server/*"
|
||||
]
|
||||
autoapi_keep_files = True
|
||||
autoapi_options = [
|
||||
'members',
|
||||
'undoc-members',
|
||||
'show-inheritance',
|
||||
'show-module-summary'
|
||||
]
|
||||
autoapi_add_toctree_entry = True
|
||||
autoapi_template_dir = '_templates/autoapi'
|
||||
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
|
|
@ -64,7 +105,7 @@ templates_path = ['_templates']
|
|||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
source_suffix = ['.rst', '.md']
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
|
@ -74,12 +115,15 @@ master_doc = 'index'
|
|||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
language = "English"
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = []
|
||||
exclude_patterns = [
|
||||
"openpype.hosts.resolve.*",
|
||||
"openpype.tools.*"
|
||||
]
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'friendly'
|
||||
|
|
@ -97,15 +141,22 @@ autosummary_generate = True
|
|||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme = 'revitron_sphinx_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
html_theme_options = {
|
||||
'collapse_navigation': False
|
||||
'collapse_navigation': True,
|
||||
'sticky_navigation': True,
|
||||
'navigation_depth': 4,
|
||||
'includehidden': True,
|
||||
'titles_only': False,
|
||||
'github_url': '',
|
||||
}
|
||||
html_logo = '_static/AYON_tight_G.svg'
|
||||
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
|
|
@ -153,8 +204,8 @@ latex_elements = {
|
|||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'pype.tex', 'pype Documentation',
|
||||
'OrbiTools', 'manual'),
|
||||
(master_doc, 'openpype.tex', 'OpenPype Documentation',
|
||||
'Ynput', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -163,7 +214,7 @@ latex_documents = [
|
|||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'pype', 'pype Documentation',
|
||||
(master_doc, 'openpype', 'OpenPype Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
|
|
@ -174,8 +225,8 @@ man_pages = [
|
|||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'pype', 'pype Documentation',
|
||||
author, 'pype', 'One line description of project.',
|
||||
(master_doc, 'OpenPype', 'OpenPype Documentation',
|
||||
author, 'OpenPype', 'Pipeline for studios',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
|
|
@ -207,7 +258,4 @@ intersphinx_mapping = {
|
|||
'https://docs.python.org/3/': None
|
||||
}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
myst_gfm_only = True
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
igniter.bootstrap\_repos module
|
||||
===============================
|
||||
|
||||
.. automodule:: igniter.bootstrap_repos
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
igniter.install\_dialog module
|
||||
==============================
|
||||
|
||||
.. automodule:: igniter.install_dialog
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
igniter.install\_thread module
|
||||
==============================
|
||||
|
||||
.. automodule:: igniter.install_thread
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
igniter package
|
||||
===============
|
||||
|
||||
.. automodule:: igniter
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
igniter.bootstrap\_repos module
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: igniter.bootstrap_repos
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
igniter.install\_dialog module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: igniter.install_dialog
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
igniter.install\_thread module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: igniter.install_thread
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
igniter.tools module
|
||||
--------------------
|
||||
|
||||
.. automodule:: igniter.tools
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
igniter.tools module
|
||||
====================
|
||||
|
||||
.. automodule:: igniter.tools
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
.. pype documentation master file, created by
|
||||
.. openpype documentation master file, created by
|
||||
sphinx-quickstart on Mon May 13 17:18:23 2019.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to pype's documentation!
|
||||
================================
|
||||
Welcome to OpenPype's API documentation!
|
||||
========================================
|
||||
|
||||
.. toctree::
|
||||
readme
|
||||
modules
|
||||
|
||||
Readme <readme.rst>
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
igniter
|
||||
=======
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 6
|
||||
|
||||
igniter
|
||||
pype
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.action module
|
||||
==================
|
||||
|
||||
.. automodule:: pype.action
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.api module
|
||||
===============
|
||||
|
||||
.. automodule:: pype.api
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.cli module
|
||||
===============
|
||||
|
||||
.. automodule:: pype.cli
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.aftereffects package
|
||||
===============================
|
||||
|
||||
.. automodule:: pype.hosts.aftereffects
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.blender.action module
|
||||
================================
|
||||
|
||||
.. automodule:: pype.hosts.blender.action
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.blender.plugin module
|
||||
================================
|
||||
|
||||
.. automodule:: pype.hosts.blender.plugin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
pype.hosts.blender package
|
||||
==========================
|
||||
|
||||
.. automodule:: pype.hosts.blender
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.blender.action module
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.blender.action
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.blender.plugin module
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.blender.plugin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.celaction.cli module
|
||||
===============================
|
||||
|
||||
.. automodule:: pype.hosts.celaction.cli
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
pype.hosts.celaction package
|
||||
============================
|
||||
|
||||
.. automodule:: pype.hosts.celaction
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.celaction.cli module
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.celaction.cli
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.lib module
|
||||
============================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.menu module
|
||||
=============================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.pipeline module
|
||||
=================================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.pipeline
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
pype.hosts.fusion package
|
||||
=========================
|
||||
|
||||
.. automodule:: pype.hosts.fusion
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Subpackages
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 6
|
||||
|
||||
pype.hosts.fusion.scripts
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.fusion.lib module
|
||||
----------------------------
|
||||
|
||||
.. automodule:: pype.hosts.fusion.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.scripts.duplicate\_with\_inputs module
|
||||
========================================================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.scripts.duplicate_with_inputs
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.scripts.fusion\_switch\_shot module
|
||||
=====================================================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.scripts.fusion_switch_shot
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
pype.hosts.fusion.scripts package
|
||||
=================================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.scripts
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.fusion.scripts.fusion\_switch\_shot module
|
||||
-----------------------------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.fusion.scripts.fusion_switch_shot
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.fusion.scripts.publish\_filesequence module
|
||||
------------------------------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.fusion.scripts.publish_filesequence
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.scripts.set\_rendermode module
|
||||
================================================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.scripts.set_rendermode
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.fusion.utils module
|
||||
==============================
|
||||
|
||||
.. automodule:: pype.hosts.fusion.utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.harmony package
|
||||
==========================
|
||||
|
||||
.. automodule:: pype.hosts.harmony
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.hiero.events module
|
||||
==============================
|
||||
|
||||
.. automodule:: pype.hosts.hiero.events
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.hiero.lib module
|
||||
===========================
|
||||
|
||||
.. automodule:: pype.hosts.hiero.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.hiero.menu module
|
||||
============================
|
||||
|
||||
.. automodule:: pype.hosts.hiero.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
pype.hosts.hiero package
|
||||
========================
|
||||
|
||||
.. automodule:: pype.hosts.hiero
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 10
|
||||
|
||||
pype.hosts.hiero.events
|
||||
pype.hosts.hiero.lib
|
||||
pype.hosts.hiero.menu
|
||||
pype.hosts.hiero.tags
|
||||
pype.hosts.hiero.workio
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.hiero.tags module
|
||||
============================
|
||||
|
||||
.. automodule:: pype.hosts.hiero.tags
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.hiero.workio module
|
||||
==============================
|
||||
|
||||
.. automodule:: pype.hosts.hiero.workio
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.houdini.lib module
|
||||
=============================
|
||||
|
||||
.. automodule:: pype.hosts.houdini.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
pype.hosts.houdini package
|
||||
==========================
|
||||
|
||||
.. automodule:: pype.hosts.houdini
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.houdini.lib module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: pype.hosts.houdini.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.maya.action module
|
||||
=============================
|
||||
|
||||
.. automodule:: pype.hosts.maya.action
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.maya.customize module
|
||||
================================
|
||||
|
||||
.. automodule:: pype.hosts.maya.customize
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.maya.expected\_files module
|
||||
======================================
|
||||
|
||||
.. automodule:: pype.hosts.maya.expected_files
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.maya.lib module
|
||||
==========================
|
||||
|
||||
.. automodule:: pype.hosts.maya.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.maya.menu module
|
||||
===========================
|
||||
|
||||
.. automodule:: pype.hosts.maya.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.maya.plugin module
|
||||
=============================
|
||||
|
||||
.. automodule:: pype.hosts.maya.plugin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
pype.hosts.maya package
|
||||
=======================
|
||||
|
||||
.. automodule:: pype.hosts.maya
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.maya.action module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: pype.hosts.maya.action
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.maya.customize module
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.maya.customize
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.maya.expected\_files module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.maya.expected_files
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.maya.lib module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: pype.hosts.maya.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.maya.menu module
|
||||
---------------------------
|
||||
|
||||
.. automodule:: pype.hosts.maya.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.maya.plugin module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: pype.hosts.maya.plugin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.nuke.actions module
|
||||
==============================
|
||||
|
||||
.. automodule:: pype.hosts.nuke.actions
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.nuke.lib module
|
||||
==========================
|
||||
|
||||
.. automodule:: pype.hosts.nuke.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.nuke.menu module
|
||||
===========================
|
||||
|
||||
.. automodule:: pype.hosts.nuke.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.nuke.plugin module
|
||||
=============================
|
||||
|
||||
.. automodule:: pype.hosts.nuke.plugin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.nuke.presets module
|
||||
==============================
|
||||
|
||||
.. automodule:: pype.hosts.nuke.presets
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
pype.hosts.nuke package
|
||||
=======================
|
||||
|
||||
.. automodule:: pype.hosts.nuke
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.nuke.actions module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nuke.actions
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nuke.lib module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nuke.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nuke.menu module
|
||||
---------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nuke.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nuke.plugin module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nuke.plugin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nuke.presets module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nuke.presets
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nuke.utils module
|
||||
----------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nuke.utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.nuke.utils module
|
||||
============================
|
||||
|
||||
.. automodule:: pype.hosts.nuke.utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
pype.hosts.nukestudio package
|
||||
=============================
|
||||
|
||||
.. automodule:: pype.hosts.nukestudio
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pype.hosts.nukestudio.events module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nukestudio.events
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nukestudio.lib module
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nukestudio.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nukestudio.menu module
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nukestudio.menu
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nukestudio.tags module
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nukestudio.tags
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pype.hosts.nukestudio.workio module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: pype.hosts.nukestudio.workio
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.photoshop package
|
||||
============================
|
||||
|
||||
.. automodule:: pype.hosts.photoshop
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
pype.hosts.premiere.lib module
|
||||
==============================
|
||||
|
||||
.. automodule:: pype.hosts.premiere.lib
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue