AYON: Bundle distribution (#5209)

* modified distribution to use bundles

* use bundles in modules discovery logic

* removed unused import

* added support for bundle settings getter

* added script launch mechanism to ayon start script

* show login UI through subprocess

* removed silent mode

* removed unused variable

* match env variables to ayon launcher

* moved ui lib function to ayon common

* raise custom exception on missing bundle name

* implemented missing bundle window to show issues with bundles

* implemented helper function to show dialog about issues with bundle

* handle issues with bundles

* removed unused import

* dont convert passed addons infor

* access keys only in server getters

* fix accessed attribute

* fix test

* fixed missing 'message' variable

* removed duplicated data

* removed unnecessary 'sha256' variable

* use lstrip instead of replacement

* use f-string

* move import to the top of file

* added some dosctrings

* change type

* use f-string

* fix grammar

* set default settings variant in global connection creation

* reuse new function

* added init file

* safe access to optional keys

* removed unnecessary condition

* modified print messages on issues with bundles

* Changed message in missing bundle window

* updated ayon_api to 0.3.2
This commit is contained in:
Jakub Trllo 2023-07-05 17:14:22 +02:00 committed by Jakub Trllo
parent 301375c401
commit 2791aa84bb
25 changed files with 1741 additions and 917 deletions

View file

@ -9,6 +9,7 @@ import site
import traceback
import contextlib
# Enabled logging debug mode when "--debug" is passed
if "--verbose" in sys.argv:
expected_values = (
@ -48,35 +49,55 @@ if "--verbose" in sys.argv:
))
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"
_silent_commands = {
"run",
"standalonepublisher",
"extractenvironments",
"version"
}
if "--headless" in sys.argv:
os.environ["AYON_HEADLESS_MODE"] = "1"
os.environ["OPENPYPE_HEADLESS_MODE"] = "1"
sys.argv.remove("--headless")
elif os.getenv("OPENPYPE_HEADLESS_MODE") != "1":
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.environ.get("OPENPYPE_HEADLESS_MODE") == "1"
SILENT_MODE_ENABLED = any(arg in _silent_commands for arg in sys.argv)
HEADLESS_MODE_ENABLED = os.getenv("AYON_HEADLESS_MODE") == "1"
_pythonpath = os.getenv("PYTHONPATH", "")
_python_paths = _pythonpath.split(os.pathsep)
@ -129,10 +150,12 @@ os.environ["PYTHONPATH"] = os.pathsep.join(_python_paths)
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"
@ -153,9 +176,7 @@ if sys.__stdout__:
term = blessed.Terminal()
def _print(message: str):
if SILENT_MODE_ENABLED:
pass
elif message.startswith("!!! "):
if message.startswith("!!! "):
print(f'{term.orangered2("!!! ")}{message[4:]}')
elif message.startswith(">>> "):
print(f'{term.aquamarine3(">>> ")}{message[4:]}')
@ -179,8 +200,7 @@ if sys.__stdout__:
print(message)
else:
def _print(message: str):
if not SILENT_MODE_ENABLED:
print(message)
print(message)
# if SSL_CERT_FILE is not set prior to OpenPype launch, we set it to point
@ -190,7 +210,9 @@ if not os.getenv("SSL_CERT_FILE"):
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,
@ -200,7 +222,11 @@ from ayon_common.connection.credentials import (
create_global_connection,
confirm_server_login,
)
from ayon_common.distribution.addon_distribution import AyonDistribution
from ayon_common.distribution import (
AyonDistribution,
BundleNotFoundError,
show_missing_bundle_information,
)
def set_global_environments() -> None:
@ -286,8 +312,44 @@ def _check_and_update_from_ayon_server():
"""
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
@ -311,8 +373,6 @@ def boot():
os.environ["OPENPYPE_VERSION"] = __version__
os.environ["AYON_VERSION"] = __version__
use_staging = os.environ.get("OPENPYPE_USE_STAGING") == "1"
_connect_to_ayon_server()
_check_and_update_from_ayon_server()
@ -328,7 +388,10 @@ def boot():
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 ...")
@ -338,8 +401,8 @@ def boot():
set_addons_environments()
# print info when not running scripts defined in 'silent commands'
if not SILENT_MODE_ENABLED:
info = get_info(use_staging)
if not SKIP_HEADERS:
info = get_info(is_staging_enabled())
info.insert(0, f">>> Using AYON from [ {AYON_ROOT} ]")
t_width = 20
@ -361,6 +424,29 @@ def boot():
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."""
@ -369,6 +455,7 @@ def get_info(use_staging=None) -> list:
inf.append(("AYON variant", "staging"))
else:
inf.append(("AYON variant", "production"))
inf.append(("AYON bundle", os.getenv("AYON_BUNDLE")))
# NOTE add addons information
@ -380,5 +467,17 @@ def get_info(use_staging=None) -> list:
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__":
boot()
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

@ -13,6 +13,8 @@ import json
import platform
import datetime
import contextlib
import subprocess
import tempfile
from typing import Optional, Union, Any
import ayon_api
@ -25,7 +27,12 @@ from ayon_api.utils import (
logout_from_server,
)
from ayon_common.utils import get_ayon_appdirs, get_local_site_id
from ayon_common.utils import (
get_ayon_appdirs,
get_local_site_id,
get_ayon_launch_args,
is_staging_enabled,
)
class ChangeUserResult:
@ -258,6 +265,8 @@ def ask_to_login_ui(
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
@ -267,12 +276,33 @@ def ask_to_login_ui(
tuple[str, str, str]: Url, user's token and username.
"""
from .ui import ask_to_login
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)
return ask_to_login(url, username, always_on_top=always_on_top)
data = {
"url": url,
"username": username,
"always_on_top": always_on_top,
}
with tempfile.TemporaryFile(
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:
@ -421,15 +451,18 @@ def set_environments(url: str, token: str):
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'.
"""
if hasattr(ayon_api, "create_connection"):
ayon_api.create_connection(
get_local_site_id(), os.environ.get("AYON_VERSION")
)
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:

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

@ -10,12 +10,12 @@ from ayon_common.resources import (
get_icon_path,
load_stylesheet,
)
from ayon_common.ui_utils import set_style_property, get_qt_app
from .widgets import (
PressHoverButton,
PlaceholderLineEdit,
)
from .lib import set_style_property
class LogoutConfirmDialog(QtWidgets.QDialog):
@ -650,16 +650,7 @@ def ask_to_login(url=None, username=None, always_on_top=False):
be changed during dialog lifetime that's why the url is returned.
"""
app_instance = QtWidgets.QApplication.instance()
if app_instance is None:
for attr_name in (
"AA_EnableHighDpiScaling",
"AA_UseHighDpiPixmaps"
):
attr = getattr(QtCore.Qt, attr_name, None)
if attr is not None:
QtWidgets.QApplication.setAttribute(attr)
app_instance = QtWidgets.QApplication([])
app_instance = get_qt_app()
window = ServerLoginWindow()
if always_on_top:
@ -701,17 +692,7 @@ def change_user(url, username, api_key, always_on_top=False):
during dialog lifetime that's why the url is returned.
"""
app_instance = QtWidgets.QApplication.instance()
if app_instance is None:
for attr_name in (
"AA_EnableHighDpiScaling",
"AA_UseHighDpiPixmaps"
):
attr = getattr(QtCore.Qt, attr_name, None)
if attr is not None:
QtWidgets.QApplication.setAttribute(attr)
app_instance = QtWidgets.QApplication([])
app_instance = get_qt_app()
window = ServerLoginWindow()
if always_on_top:
window.setWindowFlags(

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

View file

@ -1,213 +0,0 @@
import attr
from enum import Enum
class UrlType(Enum):
HTTP = "http"
GIT = "git"
FILESYSTEM = "filesystem"
SERVER = "server"
@attr.s
class MultiPlatformPath(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(MultiPlatformPath))
@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")
)
@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()
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)
description = attr.ib(default=None)
license = attr.ib(default=None)
authors = attr.ib(default=None)
@classmethod
def from_dict_by_version(cls, data, addon_version):
"""Addon info for specific version.
Args:
data (dict[str, Any]): Addon information from server. Should
contain information about every version under 'versions'.
addon_version (str): Addon version for which is info requested.
Returns:
Union[AddonInfo, None]: Addon info, or None if version is not
available.
"""
if not addon_version:
return None
# server payload contains info about all versions
version_data = data.get("versions", {}).get(addon_version)
if not version_data:
return None
source_info = version_data.get("clientSourceInfo")
require_distribution = source_info is not None
sources = []
unknown_sources = []
for source in (source_info or []):
addon_source = convert_source(source)
if addon_source is not None:
sources.append(addon_source)
else:
unknown_sources.append(source)
print(f"Unknown source {source.get('type')}")
full_name = "{}_{}".format(data["name"], addon_version)
return cls(
name=data.get("name"),
version=addon_version,
full_name=full_name,
require_distribution=require_distribution,
sources=sources,
unknown_sources=unknown_sources,
hash=data.get("hash"),
description=data.get("description"),
title=data.get("title"),
license=data.get("license"),
authors=data.get("authors")
)
@classmethod
def from_dict(cls, data, use_staging=False):
"""Get Addon information for production or staging version.
Args:
data (dict[str, Any]): Addon information from server. Should
contain information about every version under 'versions'.
use_staging (bool): Use staging version if set to 'True' instead
of production.
Returns:
Union[AddonInfo, None]: Addon info, or None if version is not
set or available.
"""
# Active addon must have 'productionVersion' or 'stagingVersion'
# and matching version info.
if use_staging:
addon_version = data.get("stagingVersion")
else:
addon_version = data.get("productionVersion")
return cls.from_dict_by_version(data, addon_version)
@attr.s
class DependencyItem(object):
"""Object matching payload from Server about single dependency package"""
name = attr.ib()
platform = attr.ib()
checksum = attr.ib()
require_distribution = attr.ib()
sources = attr.ib(default=attr.Factory(list))
unknown_sources = attr.ib(default=attr.Factory(list))
addon_list = attr.ib(default=attr.Factory(list))
python_modules = attr.ib(default=attr.Factory(dict))
@classmethod
def from_dict(cls, package):
sources = []
unknown_sources = []
package_sources = package.get("sources")
require_distribution = package_sources is not None
for source in (package_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)
addon_list = [f"{name}_{version}"
for name, version in
package.get("supportedAddons").items()]
return cls(
name=package.get("name"),
platform=package.get("platform"),
require_distribution=require_distribution,
sources=sources,
unknown_sources=unknown_sources,
checksum=package.get("checksum"),
addon_list=addon_list,
python_modules=package.get("pythonModules")
)

View file

@ -4,24 +4,43 @@ import json
import traceback
import collections
import datetime
from enum import Enum
from abc import abstractmethod
import attr
import logging
import platform
import shutil
import threading
from abc import ABCMeta
import platform
import attr
from enum import Enum
import ayon_api
from ayon_common.utils import get_ayon_appdirs
from .file_handler import RemoteFileHandler
from .addon_info import (
AddonInfo,
UrlType,
DependencyItem,
from ayon_common.utils import is_staging_enabled
from .utils import (
get_addons_dir,
get_dependencies_dir,
)
from .downloaders import get_default_download_factory
from .data_structures import (
AddonInfo,
DependencyItem,
Bundle,
)
NOT_SET = type("UNKNOWN", (), {"__bool__": lambda: False})()
class BundleNotFoundError(Exception):
"""Bundle name is defined but is not available on server.
Args:
bundle_name (str): Name of bundle that was not found.
"""
def __init__(self, bundle_name):
self.bundle_name = bundle_name
super().__init__(
f"Bundle '{bundle_name}' is not available on server"
)
class UpdateState(Enum):
@ -32,326 +51,6 @@ class UpdateState(Enum):
MISS_SOURCE_FILES = "miss_source_files"
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
class SourceDownloader(metaclass=ABCMeta):
log = logging.getLogger(__name__)
@classmethod
@abstractmethod
def download(cls, source, destination_dir, data, transfer_progress):
"""Returns url to 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 DownloadFactory:
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")
class OSDownloader(SourceDownloader):
@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):
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.replace(".", "") 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):
# Nothing to do - download does not copy anything
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 v4 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)
clear_ext = ext.lower().replace(".", "")
valid_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS)
if clear_ext not in valid_exts:
raise ValueError(
"Invalid file extension \"{}\". Expected {}".format(
clear_ext, ", ".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):
# Nothing to do - download does not copy anything
filename = source["filename"]
filepath = os.path.join(destination_dir, filename)
if os.path.exists(filepath) and os.path.isfile(filepath):
os.remove(filepath)
def get_dependency_package(package_name=None):
"""Returns info about currently used dependency package.
Dependency package means .venv created from all activated addons from the
server (plus libraries for core Tray app TODO confirm).
This package needs to be downloaded, unpacked and added to sys.path for
Tray app to work.
Args:
package_name (str): Name of package. Production package name is used
if not entered.
Returns:
Union[DependencyItem, None]: Item or None if package with the name was
not found.
"""
dependencies_info = ayon_api.get_dependencies_info()
dependency_list = dependencies_info["packages"]
# Use production package if package is not specified
if package_name is None:
package_name = dependencies_info["productionPackage"]
for dependency in dependency_list:
dependency_package = DependencyItem.from_dict(dependency)
if dependency_package.name == package_name:
return dependency_package
class DistributeTransferProgress:
"""Progress of single source item in 'DistributionItem'.
@ -612,7 +311,8 @@ class DistributionItem:
try:
downloader = self.factory.get_downloader(source.type)
except Exception:
source_progress.set_failed(f"Unknown downloader {source.type}")
message = f"Unknown downloader {source.type}"
source_progress.set_failed(message)
self.log.warning(message, exc_info=True)
continue
@ -733,12 +433,18 @@ class AyonDistribution:
dependency_dirpath (Optional[str]): Where dependencies will be stored.
dist_factory (Optional[DownloadFactory]): Factory which cares about
downloading of items based on source type.
addons_info (Optional[List[AddonInfo]]): List of prepared addons' info.
dependency_package_info (Optional[Union[Dict[str, Any], None]]): Info
about package from server. Defaults to '-1'.
addons_info (Optional[list[dict[str, Any]]): List of prepared
addons' info.
dependency_packages_info (Optional[list[dict[str, Any]]): Info
about packages from server.
bundles_info (Optional[Dict[str, Any]]): Info about
bundles.
bundle_name (Optional[str]): Name of bundle to use. If not passed
an environment variable 'AYON_BUNDLE_NAME' is checked for value.
When both are not available the bundle is defined by 'use_staging'
value.
use_staging (Optional[bool]): Use staging versions of an addon.
If not passed, an environment variable 'OPENPYPE_USE_STAGING' is
checked for value '1'.
If not passed, 'is_staging_enabled' is used as default value.
"""
def __init__(
@ -746,96 +452,304 @@ class AyonDistribution:
addon_dirpath=None,
dependency_dirpath=None,
dist_factory=None,
addons_info=None,
dependency_package_info=-1,
addons_info=NOT_SET,
dependency_packages_info=NOT_SET,
bundles_info=NOT_SET,
bundle_name=NOT_SET,
use_staging=None
):
self._log = None
self._dist_started = False
self._dist_finished = False
self._addons_dirpath = addon_dirpath or get_addons_dir()
self._dependency_dirpath = dependency_dirpath or get_dependencies_dir()
self._dist_factory = (
dist_factory or get_default_download_factory()
)
if isinstance(addons_info, list):
addons_info = {item.full_name: item for item in addons_info}
self._dist_started = False
self._dist_finished = False
self._log = None
if bundle_name is NOT_SET:
bundle_name = os.environ.get("AYON_BUNDLE_NAME", NOT_SET)
# Raw addons data from server
self._addons_info = addons_info
self._addons_dist_items = None
self._dependency_package = dependency_package_info
self._dependency_dist_item = -1
# Prepared data as Addon objects
self._addon_items = NOT_SET
# Distrubtion items of addons
# - only those addons and versions that should be distributed
self._addon_dist_items = NOT_SET
# Raw dependency packages data from server
self._dependency_packages_info = dependency_packages_info
# Prepared dependency packages as objects
self._dependency_packages_items = NOT_SET
# Dependency package item that should be used
self._dependency_package_item = NOT_SET
# Distribution item of dependency package
self._dependency_dist_item = NOT_SET
# Raw bundles data from server
self._bundles_info = bundles_info
# Bundles as objects
self._bundle_items = NOT_SET
# Bundle that should be used in production
self._production_bundle = NOT_SET
# Bundle that should be used in staging
self._staging_bundle = NOT_SET
# Boolean that defines if staging bundle should be used
self._use_staging = use_staging
# Specific bundle name should be used
self._bundle_name = bundle_name
# Final bundle that will be used
self._bundle = NOT_SET
@property
def use_staging(self):
"""Staging version of a bundle should be used.
This value is completely ignored if specific bundle name should
be used.
Returns:
bool: True if staging version should be used.
"""
if self._use_staging is None:
self._use_staging = os.getenv("OPENPYPE_USE_STAGING") == "1"
self._use_staging = is_staging_enabled()
return self._use_staging
@property
def log(self):
"""Helper to access logger.
Returns:
logging.Logger: Logger instance.
"""
if self._log is None:
self._log = logging.getLogger(self.__class__.__name__)
return self._log
@property
def bundles_info(self):
"""
Returns:
dict[str, dict[str, Any]]: Bundles information from server.
"""
if self._bundles_info is NOT_SET:
self._bundles_info = ayon_api.get_bundles()
return self._bundles_info
@property
def bundle_items(self):
"""
Returns:
list[Bundle]: List of bundles info.
"""
if self._bundle_items is NOT_SET:
self._bundle_items = [
Bundle.from_dict(info)
for info in self.bundles_info["bundles"]
]
return self._bundle_items
def _prepare_production_staging_bundles(self):
production_bundle = None
staging_bundle = None
for bundle in self.bundle_items:
if bundle.is_production:
production_bundle = bundle
if bundle.is_staging:
staging_bundle = bundle
self._production_bundle = production_bundle
self._staging_bundle = staging_bundle
@property
def production_bundle(self):
"""
Returns:
Union[Bundle, None]: Bundle that should be used in production.
"""
if self._production_bundle is NOT_SET:
self._prepare_production_staging_bundles()
return self._production_bundle
@property
def staging_bundle(self):
"""
Returns:
Union[Bundle, None]: Bundle that should be used in staging.
"""
if self._staging_bundle is NOT_SET:
self._prepare_production_staging_bundles()
return self._staging_bundle
@property
def bundle_to_use(self):
"""Bundle that will be used for distribution.
Bundle that should be used can be affected by 'bundle_name'
or 'use_staging'.
Returns:
Union[Bundle, None]: Bundle that will be used for distribution
or None.
Raises:
BundleNotFoundError: When bundle name to use is defined
but is not available on server.
"""
if self._bundle is NOT_SET:
if self._bundle_name is not NOT_SET:
bundle = next(
(
bundle
for bundle in self.bundle_items
if bundle.name == self._bundle_name
),
None
)
if bundle is None:
raise BundleNotFoundError(self._bundle_name)
self._bundle = bundle
elif self.use_staging:
self._bundle = self.staging_bundle
else:
self._bundle = self.production_bundle
return self._bundle
@property
def bundle_name_to_use(self):
bundle = self.bundle_to_use
return None if bundle is None else bundle.name
@property
def addons_info(self):
"""Server information about available addons.
Returns:
Dict[str, dict[str, Any]: Addon info by addon name.
"""
if self._addons_info is NOT_SET:
server_info = ayon_api.get_addons_info(details=True)
self._addons_info = server_info["addons"]
return self._addons_info
@property
def addon_items(self):
"""Information about available addons on server.
Addons may require distribution of files. For those addons will be
created 'DistributionItem' handling distribution itself.
Todos:
Add support for staging versions. Right now is supported only
production version.
Returns:
Dict[str, AddonInfo]: Addon info by full name.
Dict[str, AddonInfo]: Addon info object by addon name.
"""
if self._addons_info is None:
if self._addon_items is NOT_SET:
addons_info = {}
server_addons_info = ayon_api.get_addons_info(details=True)
for addon in server_addons_info["addons"]:
addon_info = AddonInfo.from_dict(addon, self.use_staging)
if addon_info is None:
continue
addons_info[addon_info.full_name] = addon_info
self._addons_info = addons_info
return self._addons_info
for addon in self.addons_info:
addon_info = AddonInfo.from_dict(addon)
addons_info[addon_info.name] = addon_info
self._addon_items = addons_info
return self._addon_items
@property
def dependency_package(self):
"""Information about dependency package from server.
Receive and cache dependency package information from server.
def dependency_packages_info(self):
"""Server information about available dependency packages.
Notes:
For testing purposes it is possible to pass dependency package
For testing purposes it is possible to pass dependency packages
information to '__init__'.
Returns:
Union[None, Dict[str, Any]]: None if server does not have specified
dependency package.
list[dict[str, Any]]: Dependency packages information.
"""
if self._dependency_package == -1:
self._dependency_package = get_dependency_package()
return self._dependency_package
if self._dependency_packages_info is NOT_SET:
self._dependency_packages_info = (
ayon_api.get_dependency_packages())["packages"]
return self._dependency_packages_info
def _prepare_current_addons_dist_items(self):
@property
def dependency_packages_items(self):
"""Dependency packages as objects.
Returns:
dict[str, DependencyItem]: Dependency packages as objects by name.
"""
if self._dependency_packages_items is NOT_SET:
dependenc_package_items = {}
for item in self.dependency_packages_info:
item = DependencyItem.from_dict(item)
dependenc_package_items[item.name] = item
self._dependency_packages_items = dependenc_package_items
return self._dependency_packages_items
@property
def dependency_package_item(self):
"""Dependency package item that should be used by bundle.
Returns:
Union[None, Dict[str, Any]]: None if bundle does not have
specified dependency package.
"""
if self._dependency_package_item is NOT_SET:
dependency_package_item = None
bundle = self.bundle_to_use
if bundle is not None:
package_name = bundle.dependency_packages.get(
platform.system().lower()
)
dependency_package_item = self.dependency_packages_items.get(
package_name)
self._dependency_package_item = dependency_package_item
return self._dependency_package_item
def _prepare_current_addon_dist_items(self):
addons_metadata = self.get_addons_metadata()
output = {}
for full_name, addon_info in self.addons_info.items():
if not addon_info.require_distribution:
output = []
addon_versions = {}
bundle = self.bundle_to_use
if bundle is not None:
addon_versions = bundle.addon_versions
for addon_name, addon_item in self.addon_items.items():
addon_version = addon_versions.get(addon_name)
# Addon is not in bundle -> Skip
if addon_version is None:
continue
addon_version_item = addon_item.versions.get(addon_version)
# Addon version is not available in addons info
# - TODO handle this case (raise error, skip, store, report, ...)
if addon_version_item is None:
print(
f"Version '{addon_version}' of addon '{addon_name}'"
" is not available on server."
)
continue
if not addon_version_item.require_distribution:
continue
full_name = addon_version_item.full_name
addon_dest = os.path.join(self._addons_dirpath, full_name)
self.log.debug(f"Checking {full_name} in {addon_dest}")
addon_in_metadata = (
addon_info.name in addons_metadata
and addon_info.version in addons_metadata[addon_info.name]
addon_name in addons_metadata
and addon_version_item.version in addons_metadata[addon_name]
)
if addon_in_metadata and os.path.isdir(addon_dest):
self.log.debug(
@ -848,25 +762,32 @@ class AyonDistribution:
downloader_data = {
"type": "addon",
"name": addon_info.name,
"version": addon_info.version
"name": addon_name,
"version": addon_version
}
output[full_name] = DistributionItem(
dist_item = DistributionItem(
state,
addon_dest,
addon_dest,
addon_info.hash,
addon_version_item.hash,
self._dist_factory,
list(addon_info.sources),
list(addon_version_item.sources),
downloader_data,
full_name,
self.log
)
output.append({
"dist_item": dist_item,
"addon_name": addon_name,
"addon_version": addon_version,
"addon_item": addon_item,
"addon_version_item": addon_version_item,
})
return output
def _prepare_dependency_progress(self):
package = self.dependency_package
package = self.dependency_package_item
if package is None or not package.require_distribution:
return None
@ -898,20 +819,34 @@ class AyonDistribution:
self.log,
)
def get_addons_dist_items(self):
def get_addon_dist_items(self):
"""Addon distribution items.
These items describe source files required by addon to be available on
machine. Each item may have 0-n source information from where can be
obtained. If file is already available it's state will be 'UPDATED'.
Example output:
[
{
"dist_item": DistributionItem,
"addon_name": str,
"addon_version": str,
"addon_item": AddonInfo,
"addon_version_item": AddonVersionInfo
}, {
...
}
]
Returns:
Dict[str, DistributionItem]: Distribution items by addon fullname.
list[dict[str, Any]]: Distribution items with addon version item.
"""
if self._addons_dist_items is None:
self._addons_dist_items = self._prepare_current_addons_dist_items()
return self._addons_dist_items
if self._addon_dist_items is NOT_SET:
self._addon_dist_items = (
self._prepare_current_addon_dist_items())
return self._addon_dist_items
def get_dependency_dist_item(self):
"""Dependency package distribution item.
@ -928,7 +863,7 @@ class AyonDistribution:
does not have specified any dependency package.
"""
if self._dependency_dist_item == -1:
if self._dependency_dist_item is NOT_SET:
self._dependency_dist_item = self._prepare_dependency_progress()
return self._dependency_dist_item
@ -1049,7 +984,8 @@ class AyonDistribution:
self.update_dependency_metadata(package.name, data)
addons_info = {}
for full_name, dist_item in self.get_addons_dist_items().items():
for item in self.get_addon_dist_items():
dist_item = item["dist_item"]
if (
not dist_item.need_distribution
or dist_item.state != UpdateState.UPDATED
@ -1059,10 +995,11 @@ class AyonDistribution:
source_data = dist_item.used_source
if not source_data:
continue
addon_info = self.addons_info[full_name]
if addon_info.name not in addons_info:
addons_info[addon_info.name] = {}
addons_info[addon_info.name][addon_info.version] = {
addon_name = item["addon_name"]
addon_version = item["addon_version"]
addons_info.setdefault(addon_name, {})
addons_info[addon_name][addon_version] = {
"source": source_data,
"file_hash": dist_item.file_hash,
"distributed_dt": stored_time
@ -1082,12 +1019,14 @@ class AyonDistribution:
List[DistributionItem]: Distribution items required by server.
"""
output = []
output = [
item["dist_item"]
for item in self.get_addon_dist_items()
]
dependency_dist_item = self.get_dependency_dist_item()
if dependency_dist_item is not None:
output.append(dependency_dist_item)
for dist_item in self.get_addons_dist_items().values():
output.append(dist_item)
output.insert(0, dependency_dist_item)
return output
def distribute(self, threaded=False):
@ -1136,9 +1075,10 @@ class AyonDistribution:
):
invalid.append("Dependency package")
for addon_name, dist_item in self.get_addons_dist_items().items():
for item in self.get_addon_dist_items():
dist_item = item["dist_item"]
if dist_item.state != UpdateState.UPDATED:
invalid.append(addon_name)
invalid.append(item["addon_name"])
if not invalid:
return
@ -1172,13 +1112,5 @@ class AyonDistribution:
return output
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
def cli(*args):
raise NotImplementedError

View file

@ -0,0 +1,261 @@
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):
sources, unknown_sources = prepare_sources(package.get("sources"))
return cls(
name=package["name"],
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()
@ -69,21 +71,25 @@ class RemoteFileHandler:
@staticmethod
def download_url(
url, root, filename=None,
sha256=None, max_redirect_hops=3, headers=None
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 (dict): additional required headers - Authentication etc..
headers (Optional[dict[str, str]]): Additional required headers
- Authentication etc..
"""
root = os.path.expanduser(root)
if not filename:
filename = os.path.basename(url)
@ -91,59 +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(f"Using downloaded and verified file: {fpath}")
return
# expand redirect chain if needed
url = RemoteFileHandler._get_redirect_url(url,
max_hops=max_redirect_hops,
headers=headers)
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(f"Downloading {url} to {fpath}")
RemoteFileHandler._urlretrieve(url, fpath, headers=headers)
except (urllib.error.URLError, IOError) as e:
if url[:5] == "https":
url = url.replace("https:", "http:")
print((
"Failed download. Trying https -> http instead."
f" Downloading {url} to {fpath}"
))
RemoteFileHandler._urlretrieve(url, fpath,
headers=headers)
else:
raise e
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)
@ -153,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)
@ -191,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:
@ -229,9 +219,8 @@ class RemoteFileHandler:
chunk_size = chunk_size or 8192
with open(filename, "wb") as fh:
with urllib.request.urlopen(
urllib.request.Request(url,
headers=final_headers)) \
as response:
urllib.request.Request(url, headers=final_headers)
) as response:
for chunk in iter(lambda: response.read(chunk_size), ""):
if not chunk:
break
@ -245,12 +234,12 @@ class RemoteFileHandler:
final_headers.update(headers)
for _ in range(max_hops + 1):
with urllib.request.urlopen(
urllib.request.Request(url,
headers=final_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. "
@ -260,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

@ -1,20 +1,33 @@
import pytest
import attr
import os
import sys
import copy
import tempfile
from common.ayon_common.distribution.addon_distribution import (
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,
AddonInfo,
AyonDistribution,
UpdateState
)
from common.ayon_common.distribution.addon_info import UrlType
from common.ayon_common.distribution.control import (
AyonDistribution,
UpdateState,
)
from common.ayon_common.distribution.data_structures import (
AddonInfo,
UrlType,
)
@pytest.fixture
def addon_download_factory():
def download_factory():
addon_downloader = DownloadFactory()
addon_downloader.register_format(UrlType.FILESYSTEM, OSDownloader)
addon_downloader.register_format(UrlType.HTTP, HTTPDownloader)
@ -23,65 +36,84 @@ def addon_download_factory():
@pytest.fixture
def http_downloader(addon_download_factory):
yield addon_download_factory.get_downloader(UrlType.HTTP.value)
def http_downloader(download_factory):
yield download_factory.get_downloader(UrlType.HTTP.value)
@pytest.fixture
def temp_folder():
yield tempfile.mkdtemp()
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():
addon_info = {
"versions": {
yield {
"name": "slack",
"title": "Slack addon",
"versions": {
"1.0.0": {
"clientPyproject": {
"tool": {
"poetry": {
"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"
"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",
"path": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing", # noqa
"filename": "dummy.zip"
},
{
"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"
}
}
}
}
},
"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": "",
"title": "Slack addon",
"name": "openpype_slack",
"productionVersion": "1.0.0",
"hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658" # noqa
},
"description": ""
}
yield addon_info
def test_register(printer):
@ -103,21 +135,16 @@ def test_get_downloader(printer, download_factory):
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
"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"
}
}
]
@ -127,18 +154,10 @@ def test_addon_info(printer, sample_addon_info):
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"
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"
@ -147,37 +166,83 @@ def test_addon_info(printer, sample_addon_info):
assert addon_as_dict["name"], "Dict approach should work"
def test_update_addon_state(printer, sample_addon_info,
temp_folder, download_factory):
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_info = AddonInfo.from_dict(sample_addon_info)
orig_hash = addon_info.hash
addon_version = list(sample_addon_info["versions"])[0]
broken_addon_info = copy.deepcopy(sample_addon_info)
# Cause crash because of invalid hash
addon_info.hash = "brokenhash"
broken_addon_info["versions"][addon_version]["hash"] = "brokenhash"
distribution = AyonDistribution(
temp_folder, temp_folder, download_factory, [addon_info], None
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_addons_dist_items()
slack_state = dist_items["openpype_slack_1.0.0"].state
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
addon_info.hash = orig_hash
distribution = AyonDistribution(
temp_folder, temp_folder, download_factory, [addon_info], None
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_addons_dist_items()
assert dist_items["openpype_slack_1.0.0"].state == UpdateState.UPDATED, (
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(
temp_folder, temp_folder, download_factory, [addon_info], None
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_addons_dist_items()
assert dist_items["openpype_slack_1.0.0"].state == UpdateState.UPDATED, (
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)

View file

@ -1,5 +1,7 @@
import os
from ayon_common.utils import is_staging_enabled
RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
@ -10,7 +12,7 @@ def get_resource_path(*args):
def get_icon_path():
if os.environ.get("OPENPYPE_USE_STAGING") == "1":
if is_staging_enabled():
return get_resource_path("AYON_staging.png")
return get_resource_path("AYON.png")

View file

@ -81,4 +81,4 @@ QLineEdit[state="invalid"] {
}
#LikeDisabledInput:focus {
border-color: #373D48;
}
}

View file

@ -1,6 +1,9 @@
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.
@ -18,8 +21,23 @@ def get_ayon_appdirs(*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."""
"""Create a local site identifier.
Returns:
str: Randomly generated site id.
"""
from coolname import generate_slug
new_id = generate_slug(3)
@ -33,6 +51,9 @@ 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
@ -50,3 +71,20 @@ def get_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,5 +1,6 @@
# -*- coding: utf-8 -*-
"""Base class for Pype Modules."""
import copy
import os
import sys
import json
@ -319,7 +320,35 @@ def _get_ayon_addons_information():
List[Dict[str, Any]]: List of addon information to use.
"""
return ayon_api.get_addons_info()["addons"]
output = []
bundle_name = os.getenv("AYON_BUNDLE_NAME")
bundles = ayon_api.get_bundles()["bundles"]
final_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == bundle_name
),
None
)
if final_bundle is None:
return output
bundle_addons = final_bundle["addons"]
addons = ayon_api.get_addons_info()["addons"]
for addon in addons:
name = addon["name"]
versions = addon.get("versions")
addon_version = bundle_addons.get(name)
if addon_version is None or not versions:
continue
version = versions.get(addon_version)
if version:
version = copy.deepcopy(version)
version["name"] = name
version["version"] = addon_version
output.append(version)
return output
def _load_ayon_addons(openpype_modules, modules_key, log):
@ -354,15 +383,9 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
))
return v3_addons_to_skip
version_key = (
"stagingVersion" if is_staging_enabled()
else "productionVersion"
)
for addon_info in addons_info:
addon_name = addon_info["name"]
addon_version = addon_info.get(version_key)
if not addon_version:
continue
addon_version = addon_info["version"]
folder_name = "{}_{}".format(addon_name, addon_version)
addon_dir = os.path.join(addons_dir, folder_name)

View file

@ -13,7 +13,8 @@ Main entrypoints are functions:
- get_ayon_project_settings - replacement for 'get_project_settings'
- get_ayon_system_settings - replacement for 'get_system_settings'
"""
import os
import collections
import json
import copy
import time
@ -1275,9 +1276,15 @@ def convert_project_settings(ayon_settings, default_settings):
class CacheItem:
lifetime = 10
def __init__(self, value):
def __init__(self, value, outdate_time=None):
self._value = value
self._outdate_time = time.time() + self.lifetime
if outdate_time is None:
outdate_time = time.time() + self.lifetime
self._outdate_time = outdate_time
@classmethod
def create_outdated(cls):
return cls({}, 0)
def get_value(self):
return copy.deepcopy(self._value)
@ -1291,57 +1298,89 @@ class CacheItem:
return time.time() > self._outdate_time
class AyonSettingsCache:
_cache_by_project_name = {}
_production_settings = None
class _AyonSettingsCache:
use_bundles = None
variant = None
addon_versions = CacheItem.create_outdated()
studio_settings = CacheItem.create_outdated()
cache_by_project_name = collections.defaultdict(
CacheItem.create_outdated)
@classmethod
def get_production_settings(cls):
if (
cls._production_settings is None
or cls._production_settings.is_outdated
):
def _use_bundles(cls):
if _AyonSettingsCache.use_bundles is None:
major, minor, _, _, _ = ayon_api.get_server_version_tuple()
_AyonSettingsCache.use_bundles = major == 0 and minor >= 3
return _AyonSettingsCache.use_bundles
@classmethod
def _get_variant(cls):
if _AyonSettingsCache.variant is None:
from openpype.lib.openpype_version import is_staging_enabled
variant = "staging" if is_staging_enabled() else "production"
value = ayon_api.get_addons_settings(
only_values=False, variant=variant)
if cls._production_settings is None:
cls._production_settings = CacheItem(value)
else:
cls._production_settings.update_value(value)
return cls._production_settings.get_value()
_AyonSettingsCache.variant = (
"staging" if is_staging_enabled() else "production"
)
return _AyonSettingsCache.variant
@classmethod
def _get_bundle_name(cls):
return os.environ["AYON_BUNDLE_NAME"]
@classmethod
def get_value_by_project(cls, project_name):
production_settings = cls.get_production_settings()
addon_versions = production_settings["versions"]
if project_name is None:
return production_settings["settings"], addon_versions
cache_item = cls._cache_by_project_name.get(project_name)
if cache_item is None or cache_item.is_outdated:
value = ayon_api.get_addons_settings(project_name)
if cache_item is None:
cache_item = CacheItem(value)
cls._cache_by_project_name[project_name] = cache_item
cache_item = _AyonSettingsCache.cache_by_project_name[project_name]
if cache_item.is_outdated:
if cls._use_bundles():
value = ayon_api.get_addons_settings(
bundle_name=cls._get_bundle_name(),
project_name=project_name
)
else:
cache_item.update_value(value)
value = ayon_api.get_addons_settings(project_name)
cache_item.update_value(value)
return cache_item.get_value()
return cache_item.get_value(), addon_versions
@classmethod
def _get_addon_versions_from_bundle(cls):
expected_bundle = cls._get_bundle_name()
bundles = ayon_api.get_bundles()["bundles"]
bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == expected_bundle
),
None
)
if bundle is not None:
return bundle["addons"]
return {}
@classmethod
def get_addon_versions(cls):
cache_item = _AyonSettingsCache.addon_versions
if cache_item.is_outdated:
if cls._use_bundles():
addons = cls._get_addon_versions_from_bundle()
else:
settings_data = ayon_api.get_addons_settings(
only_values=False, variant=cls._get_variant())
addons = settings_data["versions"]
cache_item.update_value(addons)
return cache_item.get_value()
def get_ayon_project_settings(default_values, project_name):
ayon_settings, addon_versions = (
AyonSettingsCache.get_value_by_project(project_name)
)
ayon_settings = _AyonSettingsCache.get_value_by_project(project_name)
return convert_project_settings(ayon_settings, default_values)
def get_ayon_system_settings(default_values):
ayon_settings, addon_versions = (
AyonSettingsCache.get_value_by_project(None)
)
addon_versions = _AyonSettingsCache.get_addon_versions()
ayon_settings = _AyonSettingsCache.get_value_by_project(None)
return convert_system_settings(
ayon_settings, default_values, addon_versions
)

View file

@ -1,6 +1,8 @@
from .version import __version__
from .utils import (
TransferProgress,
slugify_string,
create_dependency_package_basename,
)
from .server_api import (
ServerAPI,
@ -183,8 +185,11 @@ from ._api import (
__all__ = (
"__version__",
"TransferProgress",
"slugify_string",
"create_dependency_package_basename",
"ServerAPI",

View file

@ -61,6 +61,7 @@ from .utils import (
entity_data_json_default,
failed_json_default,
TransferProgress,
create_dependency_package_basename,
)
PatternType = type(re.compile(""))
@ -114,6 +115,10 @@ class RestApiResponse(object):
self.status = status_code
self._data = data
@property
def text(self):
return self._response.text
@property
def orig_response(self):
return self._response
@ -150,11 +155,13 @@ class RestApiResponse(object):
def status_code(self):
return self.status
def raise_for_status(self):
def raise_for_status(self, message=None):
try:
self._response.raise_for_status()
except requests.exceptions.HTTPError as exc:
raise HTTPRequestError(str(exc), exc.response)
if message is None:
message = str(exc)
raise HTTPRequestError(message, exc.response)
def __enter__(self, *args, **kwargs):
return self._response.__enter__(*args, **kwargs)
@ -1303,13 +1310,15 @@ class ServerAPI(object):
progress.set_transfer_done()
return progress
def _upload_file(self, url, filepath, progress):
def _upload_file(self, url, filepath, progress, request_type=None):
if request_type is None:
request_type = RequestTypes.put
kwargs = {}
if self._session is None:
kwargs["headers"] = self.get_headers()
post_func = self._base_functions_mapping[RequestTypes.post]
post_func = self._base_functions_mapping[request_type]
else:
post_func = self._session_functions_mapping[RequestTypes.post]
post_func = self._session_functions_mapping[request_type]
with open(filepath, "rb") as stream:
stream.seek(0, io.SEEK_END)
@ -1320,7 +1329,9 @@ class ServerAPI(object):
response.raise_for_status()
progress.set_transferred_size(size)
def upload_file(self, endpoint, filepath, progress=None):
def upload_file(
self, endpoint, filepath, progress=None, request_type=None
):
"""Upload file to server.
Todos:
@ -1331,6 +1342,8 @@ class ServerAPI(object):
filepath (str): Source filepath.
progress (Optional[TransferProgress]): Object that gives ability
to track upload progress.
request_type (Optional[RequestType]): Type of request that will
be used to upload file.
"""
if endpoint.startswith(self._base_url):
@ -1349,7 +1362,7 @@ class ServerAPI(object):
progress.set_started()
try:
self._upload_file(url, filepath, progress)
self._upload_file(url, filepath, progress, request_type)
except Exception as exc:
progress.set_failed(str(exc))
@ -1486,13 +1499,11 @@ class ServerAPI(object):
"""
response = self.delete("attributes/{}".format(attribute_name))
if response.status_code != 204:
# TODO raise different exception
raise ValueError(
"Attribute \"{}\" was not created/updated. {}".format(
attribute_name, response.detail
)
response.raise_for_status(
"Attribute \"{}\" was not created/updated. {}".format(
attribute_name, response.detail
)
)
self.reset_attributes_schema()
@ -1732,8 +1743,9 @@ class ServerAPI(object):
python_version,
platform_name,
python_modules,
runtime_python_modules,
checksum,
checksum_type,
checksum_algorithm,
file_size,
sources=None,
):
@ -1742,6 +1754,10 @@ class ServerAPI(object):
This step will create only metadata. Make sure to upload installer
to the server using 'upload_installer' method.
Runtime python modules are modules that are required to run AYON
desktop application, but are not added to PYTHONPATH for any
subprocess.
Args:
filename (str): Installer filename.
version (str): Version of installer.
@ -1749,8 +1765,10 @@ class ServerAPI(object):
platform_name (str): Name of platform.
python_modules (dict[str, str]): Python modules that are available
in installer.
runtime_python_modules (dict[str, str]): Runtime python modules
that are available in installer.
checksum (str): Installer file checksum.
checksum_type (str): Type of checksum used to create checksum.
checksum_algorithm (str): Type of checksum used to create checksum.
file_size (int): File size.
sources (Optional[list[dict[str, Any]]]): List of sources that
can be used to download file.
@ -1762,8 +1780,9 @@ class ServerAPI(object):
"pythonVersion": python_version,
"platform": platform_name,
"pythonModules": python_modules,
"runtimePythonModules": runtime_python_modules,
"checksum": checksum,
"checksumType": checksum_type,
"checksumAlgorithm": checksum_algorithm,
"size": file_size,
}
if sources:
@ -1781,7 +1800,7 @@ class ServerAPI(object):
can be used to download file. Fully replaces existing sources.
"""
response = self.post(
response = self.patch(
"desktop/installers/{}".format(filename),
sources=sources
)
@ -1794,7 +1813,7 @@ class ServerAPI(object):
filename (str): Installer filename.
"""
response = self.delete("dekstop/installers/{}".format(filename))
response = self.delete("desktop/installers/{}".format(filename))
response.raise_for_status()
def download_installer(
@ -1929,8 +1948,7 @@ class ServerAPI(object):
checksum=checksum,
**kwargs
)
if response.status not in (200, 201, 204):
raise ServerError("Failed to create/update dependency")
response.raise_for_status("Failed to create/update dependency")
return response.data
def get_dependency_packages(self):
@ -2065,8 +2083,7 @@ class ServerAPI(object):
route = self._get_dependency_package_route(filename, platform_name)
response = self.delete(route)
if response.status != 200:
raise ServerError("Failed to delete dependency file")
response.raise_for_status("Failed to delete dependency file")
return response.data
def download_dependency_package(
@ -2131,6 +2148,10 @@ class ServerAPI(object):
def create_dependency_package_basename(self, platform_name=None):
"""Create basename for dependency package file.
Deprecated:
Use 'create_dependency_package_basename' from `ayon_api` or
`ayon_api.utils` instead.
Args:
platform_name (Optional[str]): Name of platform for which the
bundle is targeted. Default value is current platform.
@ -2139,12 +2160,7 @@ class ServerAPI(object):
str: Dependency package name with timestamp and platform.
"""
if platform_name is None:
platform_name = platform.system().lower()
now_date = datetime.datetime.now()
time_stamp = now_date.strftime("%y%m%d%H%M")
return "ayon_{}_{}".format(time_stamp, platform_name)
return create_dependency_package_basename(platform_name)
def _get_bundles_route(self):
major, minor, patch, _, _ = self.server_version_tuple

View file

@ -2,6 +2,7 @@ import re
import datetime
import uuid
import string
import platform
import collections
try:
# Python 3
@ -449,3 +450,22 @@ class TransferProgress:
destination_url = property(get_destination_url, set_destination_url)
content_size = property(get_content_size, set_content_size)
transferred_size = property(get_transferred_size, set_transferred_size)
def create_dependency_package_basename(platform_name=None):
"""Create basename for dependency package file.
Args:
platform_name (Optional[str]): Name of platform for which the
bundle is targeted. Default value is current platform.
Returns:
str: Dependency package name with timestamp and platform.
"""
if platform_name is None:
platform_name = platform.system().lower()
now_date = datetime.datetime.now()
time_stamp = now_date.strftime("%y%m%d%H%M")
return "ayon_{}_{}".format(time_stamp, platform_name)

View file

@ -1,2 +1,2 @@
"""Package declaring Python API for Ayon server."""
__version__ = "0.3.1"
__version__ = "0.3.2"