Merge branch 'develop' into maye_new_publisher_with_RR

This commit is contained in:
Petr Kalis 2023-07-17 11:08:55 +02:00 committed by GitHub
commit 7939836e65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
918 changed files with 30256 additions and 20893 deletions

View file

@ -35,6 +35,10 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.16.0
- 3.16.0-nightly.2
- 3.16.0-nightly.1
- 3.15.12
- 3.15.12-nightly.4
- 3.15.12-nightly.3
- 3.15.12-nightly.2
@ -135,6 +139,7 @@ body:
- 3.14.5-nightly.1
- 3.14.4
- 3.14.4-nightly.4
validations:
required: true
- type: dropdown

View file

@ -5,12 +5,6 @@ on:
inputs:
milestone:
required: true
release-type:
type: choice
description: What release should be created
options:
- release
- pre-release
milestone:
types: closed

1
.gitignore vendored
View file

@ -37,6 +37,7 @@ Temporary Items
###########
/build
/dist/
/server_addon/package/*
/vendor/bin/*
/vendor/python/*

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
[![All Contributors](https://img.shields.io/badge/all_contributors-28-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
OpenPype
====
========
[![documentation](https://github.com/pypeclub/pype/actions/workflows/documentation.yml/badge.svg)](https://github.com/pypeclub/pype/actions/workflows/documentation.yml) ![GitHub VFX Platform](https://img.shields.io/badge/vfx%20platform-2022-lightgrey?labelColor=303846)
@ -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
View 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()

View 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",
)

View 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()

View file

@ -0,0 +1,12 @@
from .login_window import (
ServerLoginWindow,
ask_to_login,
change_user,
)
__all__ = (
"ServerLoginWindow",
"ask_to_login",
"change_user",
)

View 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])

View 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()

View 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)

View file

@ -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!

View file

@ -0,0 +1,9 @@
from .control import AyonDistribution, BundleNotFoundError
from .utils import show_missing_bundle_information
__all__ = (
"AyonDistribution",
"BundleNotFoundError",
"show_missing_bundle_information",
)

File diff suppressed because it is too large Load diff

View 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"],
)

View 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

View file

@ -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

View file

@ -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")

View 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()

View 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)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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;
}

View 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)

View 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

View file

@ -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

View file

@ -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"))

View file

@ -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
View 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)

View file

@ -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

View 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

View 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>`_

View file

@ -0,0 +1 @@
{% extends "python/data.rst" %}

View 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 %}

View 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 %}

View file

@ -0,0 +1 @@
{% extends "python/class.rst" %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -0,0 +1 @@
{% extends "python/module.rst" %}

View 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 %}

View file

@ -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

View file

@ -1,7 +0,0 @@
igniter.bootstrap\_repos module
===============================
.. automodule:: igniter.bootstrap_repos
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
igniter.install\_dialog module
==============================
.. automodule:: igniter.install_dialog
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
igniter.install\_thread module
==============================
.. automodule:: igniter.install_thread
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
igniter.tools module
====================
.. automodule:: igniter.tools
:members:
:undoc-members:
:show-inheritance:

View file

@ -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
==================

View file

@ -1,8 +0,0 @@
igniter
=======
.. toctree::
:maxdepth: 6
igniter
pype

View file

@ -1,7 +0,0 @@
pype.action module
==================
.. automodule:: pype.action
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.api module
===============
.. automodule:: pype.api
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.cli module
===============
.. automodule:: pype.cli
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.aftereffects package
===============================
.. automodule:: pype.hosts.aftereffects
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.blender.action module
================================
.. automodule:: pype.hosts.blender.action
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.blender.plugin module
================================
.. automodule:: pype.hosts.blender.plugin
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.celaction.cli module
===============================
.. automodule:: pype.hosts.celaction.cli
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.fusion.lib module
============================
.. automodule:: pype.hosts.fusion.lib
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.fusion.menu module
=============================
.. automodule:: pype.hosts.fusion.menu
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.fusion.pipeline module
=================================
.. automodule:: pype.hosts.fusion.pipeline
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.fusion.scripts.set\_rendermode module
================================================
.. automodule:: pype.hosts.fusion.scripts.set_rendermode
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.fusion.utils module
==============================
.. automodule:: pype.hosts.fusion.utils
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.harmony package
==========================
.. automodule:: pype.hosts.harmony
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.hiero.events module
==============================
.. automodule:: pype.hosts.hiero.events
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.hiero.lib module
===========================
.. automodule:: pype.hosts.hiero.lib
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.hiero.menu module
============================
.. automodule:: pype.hosts.hiero.menu
:members:
:undoc-members:
:show-inheritance:

View file

@ -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

View file

@ -1,7 +0,0 @@
pype.hosts.hiero.tags module
============================
.. automodule:: pype.hosts.hiero.tags
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.hiero.workio module
==============================
.. automodule:: pype.hosts.hiero.workio
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.houdini.lib module
=============================
.. automodule:: pype.hosts.houdini.lib
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.maya.action module
=============================
.. automodule:: pype.hosts.maya.action
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.maya.customize module
================================
.. automodule:: pype.hosts.maya.customize
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.maya.expected\_files module
======================================
.. automodule:: pype.hosts.maya.expected_files
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.maya.lib module
==========================
.. automodule:: pype.hosts.maya.lib
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.maya.menu module
===========================
.. automodule:: pype.hosts.maya.menu
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.maya.plugin module
=============================
.. automodule:: pype.hosts.maya.plugin
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.nuke.actions module
==============================
.. automodule:: pype.hosts.nuke.actions
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.nuke.lib module
==========================
.. automodule:: pype.hosts.nuke.lib
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.nuke.menu module
===========================
.. automodule:: pype.hosts.nuke.menu
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.nuke.plugin module
=============================
.. automodule:: pype.hosts.nuke.plugin
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
pype.hosts.nuke.presets module
==============================
.. automodule:: pype.hosts.nuke.presets
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.nuke.utils module
============================
.. automodule:: pype.hosts.nuke.utils
:members:
:undoc-members:
:show-inheritance:

View file

@ -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:

View file

@ -1,7 +0,0 @@
pype.hosts.photoshop package
============================
.. automodule:: pype.hosts.photoshop
:members:
:undoc-members:
:show-inheritance:

Some files were not shown because too many files have changed in this diff Show more