From a9eaa68ac60e783691806d172056251305f4a961 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 27 Jul 2023 14:42:21 +0200 Subject: [PATCH] AYON: Remove AYON launch logic from OpenPype (#5348) * removed AYON launch logic from OpenPype * updated ayon api to 0.3.3 * removed common from include files --------- Co-authored-by: 64qam --- ayon_start.py | 483 ------- common/ayon_common/__init__.py | 16 - common/ayon_common/connection/__init__.py | 0 common/ayon_common/connection/credentials.py | 511 -------- common/ayon_common/connection/ui/__init__.py | 12 - common/ayon_common/connection/ui/__main__.py | 23 - .../ayon_common/connection/ui/login_window.py | 710 ----------- common/ayon_common/connection/ui/widgets.py | 47 - common/ayon_common/distribution/README.md | 18 - common/ayon_common/distribution/__init__.py | 9 - common/ayon_common/distribution/control.py | 1116 ----------------- .../distribution/data_structures.py | 265 ---- .../ayon_common/distribution/downloaders.py | 250 ---- .../ayon_common/distribution/file_handler.py | 289 ----- .../tests/test_addon_distributtion.py | 248 ---- .../distribution/ui/missing_bundle_window.py | 146 --- common/ayon_common/distribution/utils.py | 90 -- common/ayon_common/resources/AYON.icns | Bin 40634 -> 0 bytes common/ayon_common/resources/AYON.ico | Bin 4286 -> 0 bytes common/ayon_common/resources/AYON.png | Bin 16907 -> 0 bytes common/ayon_common/resources/AYON_staging.png | Bin 15273 -> 0 bytes common/ayon_common/resources/__init__.py | 25 - common/ayon_common/resources/edit.png | Bin 9138 -> 0 bytes common/ayon_common/resources/eye.png | Bin 2152 -> 0 bytes common/ayon_common/resources/stylesheet.css | 84 -- common/ayon_common/ui_utils.py | 36 - common/ayon_common/utils.py | 90 -- .../vendor/python/common/ayon_api/__init__.py | 4 + .../vendor/python/common/ayon_api/_api.py | 64 +- .../python/common/ayon_api/server_api.py | 163 ++- .../python/common/ayon_api/thumbnails.py | 2 +- .../vendor/python/common/ayon_api/utils.py | 139 +- .../vendor/python/common/ayon_api/version.py | 2 +- setup.py | 17 - tools/run_tray_ayon.ps1 | 41 - tools/run_tray_ayon.sh | 78 -- 36 files changed, 321 insertions(+), 4657 deletions(-) delete mode 100644 ayon_start.py delete mode 100644 common/ayon_common/__init__.py delete mode 100644 common/ayon_common/connection/__init__.py delete mode 100644 common/ayon_common/connection/credentials.py delete mode 100644 common/ayon_common/connection/ui/__init__.py delete mode 100644 common/ayon_common/connection/ui/__main__.py delete mode 100644 common/ayon_common/connection/ui/login_window.py delete mode 100644 common/ayon_common/connection/ui/widgets.py delete mode 100644 common/ayon_common/distribution/README.md delete mode 100644 common/ayon_common/distribution/__init__.py delete mode 100644 common/ayon_common/distribution/control.py delete mode 100644 common/ayon_common/distribution/data_structures.py delete mode 100644 common/ayon_common/distribution/downloaders.py delete mode 100644 common/ayon_common/distribution/file_handler.py delete mode 100644 common/ayon_common/distribution/tests/test_addon_distributtion.py delete mode 100644 common/ayon_common/distribution/ui/missing_bundle_window.py delete mode 100644 common/ayon_common/distribution/utils.py delete mode 100644 common/ayon_common/resources/AYON.icns delete mode 100644 common/ayon_common/resources/AYON.ico delete mode 100644 common/ayon_common/resources/AYON.png delete mode 100644 common/ayon_common/resources/AYON_staging.png delete mode 100644 common/ayon_common/resources/__init__.py delete mode 100644 common/ayon_common/resources/edit.png delete mode 100644 common/ayon_common/resources/eye.png delete mode 100644 common/ayon_common/resources/stylesheet.css delete mode 100644 common/ayon_common/ui_utils.py delete mode 100644 common/ayon_common/utils.py delete mode 100644 tools/run_tray_ayon.ps1 delete mode 100755 tools/run_tray_ayon.sh diff --git a/ayon_start.py b/ayon_start.py deleted file mode 100644 index 458c46bba6..0000000000 --- a/ayon_start.py +++ /dev/null @@ -1,483 +0,0 @@ -# -*- coding: utf-8 -*- -"""Main entry point for AYON command. - -Bootstrapping process of AYON. -""" -import os -import sys -import site -import traceback -import contextlib - - -# Enabled logging debug mode when "--debug" is passed -if "--verbose" in sys.argv: - expected_values = ( - "Expected: notset, debug, info, warning, error, critical" - " or integer [0-50]." - ) - idx = sys.argv.index("--verbose") - sys.argv.pop(idx) - if idx < len(sys.argv): - value = sys.argv.pop(idx) - else: - raise RuntimeError(( - f"Expect value after \"--verbose\" argument. {expected_values}" - )) - - log_level = None - low_value = value.lower() - if low_value.isdigit(): - log_level = int(low_value) - elif low_value == "notset": - log_level = 0 - elif low_value == "debug": - log_level = 10 - elif low_value == "info": - log_level = 20 - elif low_value == "warning": - log_level = 30 - elif low_value == "error": - log_level = 40 - elif low_value == "critical": - log_level = 50 - - if log_level is None: - raise ValueError(( - "Unexpected value after \"--verbose\" " - f"argument \"{value}\". {expected_values}" - )) - - os.environ["OPENPYPE_LOG_LEVEL"] = str(log_level) - os.environ["AYON_LOG_LEVEL"] = str(log_level) - -# Enable debug mode, may affect log level if log level is not defined -if "--debug" in sys.argv: - sys.argv.remove("--debug") - os.environ["AYON_DEBUG"] = "1" - os.environ["OPENPYPE_DEBUG"] = "1" - -if "--automatic-tests" in sys.argv: - sys.argv.remove("--automatic-tests") - os.environ["IS_TEST"] = "1" - -SKIP_HEADERS = False -if "--skip-headers" in sys.argv: - sys.argv.remove("--skip-headers") - SKIP_HEADERS = True - -SKIP_BOOTSTRAP = False -if "--skip-bootstrap" in sys.argv: - sys.argv.remove("--skip-bootstrap") - SKIP_BOOTSTRAP = True - -if "--use-staging" in sys.argv: - sys.argv.remove("--use-staging") - os.environ["AYON_USE_STAGING"] = "1" - os.environ["OPENPYPE_USE_STAGING"] = "1" - -if "--headless" in sys.argv: - os.environ["AYON_HEADLESS_MODE"] = "1" - os.environ["OPENPYPE_HEADLESS_MODE"] = "1" - sys.argv.remove("--headless") - -elif ( - os.getenv("AYON_HEADLESS_MODE") != "1" - or os.getenv("OPENPYPE_HEADLESS_MODE") != "1" -): - os.environ.pop("AYON_HEADLESS_MODE", None) - os.environ.pop("OPENPYPE_HEADLESS_MODE", None) - -elif ( - os.getenv("AYON_HEADLESS_MODE") - != os.getenv("OPENPYPE_HEADLESS_MODE") -): - os.environ["OPENPYPE_HEADLESS_MODE"] = ( - os.environ["AYON_HEADLESS_MODE"] - ) - -IS_BUILT_APPLICATION = getattr(sys, "frozen", False) -HEADLESS_MODE_ENABLED = os.getenv("AYON_HEADLESS_MODE") == "1" - -_pythonpath = os.getenv("PYTHONPATH", "") -_python_paths = _pythonpath.split(os.pathsep) -if not IS_BUILT_APPLICATION: - # Code root defined by `start.py` directory - AYON_ROOT = os.path.dirname(os.path.abspath(__file__)) - _dependencies_path = site.getsitepackages()[-1] -else: - AYON_ROOT = os.path.dirname(sys.executable) - - # add dependencies folder to sys.pat for frozen code - _dependencies_path = os.path.normpath( - os.path.join(AYON_ROOT, "dependencies") - ) -# add stuff from `/dependencies` to PYTHONPATH. -sys.path.append(_dependencies_path) -_python_paths.append(_dependencies_path) - -# Vendored python modules that must not be in PYTHONPATH environment but -# are required for OpenPype processes -sys.path.insert(0, os.path.join(AYON_ROOT, "vendor", "python")) - -# Add common package to sys path -# - common contains common code for bootstraping and OpenPype processes -sys.path.insert(0, os.path.join(AYON_ROOT, "common")) - -# This is content of 'core' addon which is ATM part of build -common_python_vendor = os.path.join( - AYON_ROOT, - "openpype", - "vendor", - "python", - "common" -) -# Add tools dir to sys path for pyblish UI discovery -tools_dir = os.path.join(AYON_ROOT, "openpype", "tools") -for path in (AYON_ROOT, common_python_vendor, tools_dir): - while path in _python_paths: - _python_paths.remove(path) - - while path in sys.path: - sys.path.remove(path) - - _python_paths.insert(0, path) - sys.path.insert(0, path) - -os.environ["PYTHONPATH"] = os.pathsep.join(_python_paths) - -# enabled AYON state -os.environ["USE_AYON_SERVER"] = "1" -# Set this to point either to `python` from venv in case of live code -# or to `ayon` or `ayon_console` in case of frozen code -os.environ["AYON_EXECUTABLE"] = sys.executable -os.environ["OPENPYPE_EXECUTABLE"] = sys.executable -os.environ["AYON_ROOT"] = AYON_ROOT -os.environ["OPENPYPE_ROOT"] = AYON_ROOT -os.environ["OPENPYPE_REPOS_ROOT"] = AYON_ROOT -os.environ["AYON_MENU_LABEL"] = "AYON" -os.environ["AVALON_LABEL"] = "AYON" -# Set name of pyblish UI import -os.environ["PYBLISH_GUI"] = "pyblish_pype" -# Set builtin OCIO root -os.environ["BUILTIN_OCIO_ROOT"] = os.path.join( - AYON_ROOT, - "vendor", - "bin", - "ocioconfig", - "OpenColorIOConfigs" -) - -import blessed # noqa: E402 -import certifi # noqa: E402 - - -if sys.__stdout__: - term = blessed.Terminal() - - def _print(message: str): - if message.startswith("!!! "): - print(f'{term.orangered2("!!! ")}{message[4:]}') - elif message.startswith(">>> "): - print(f'{term.aquamarine3(">>> ")}{message[4:]}') - elif message.startswith("--- "): - print(f'{term.darkolivegreen3("--- ")}{message[4:]}') - elif message.startswith("*** "): - print(f'{term.gold("*** ")}{message[4:]}') - elif message.startswith(" - "): - print(f'{term.wheat(" - ")}{message[4:]}') - elif message.startswith(" . "): - print(f'{term.tan(" . ")}{message[4:]}') - elif message.startswith(" - "): - print(f'{term.seagreen3(" - ")}{message[7:]}') - elif message.startswith(" ! "): - print(f'{term.goldenrod(" ! ")}{message[7:]}') - elif message.startswith(" * "): - print(f'{term.aquamarine1(" * ")}{message[7:]}') - elif message.startswith(" "): - print(f'{term.darkseagreen3(" ")}{message[4:]}') - else: - print(message) -else: - def _print(message: str): - print(message) - - -# if SSL_CERT_FILE is not set prior to OpenPype launch, we set it to point -# to certifi bundle to make sure we have reasonably new CA certificates. -if not os.getenv("SSL_CERT_FILE"): - os.environ["SSL_CERT_FILE"] = certifi.where() -elif os.getenv("SSL_CERT_FILE") != certifi.where(): - _print("--- your system is set to use custom CA certificate bundle.") - -from ayon_api import get_base_url -from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY -from ayon_common import is_staging_enabled -from ayon_common.connection.credentials import ( - ask_to_login_ui, - add_server, - need_server_or_login, - load_environments, - set_environments, - create_global_connection, - confirm_server_login, -) -from ayon_common.distribution import ( - AyonDistribution, - BundleNotFoundError, - show_missing_bundle_information, -) - - -def set_global_environments() -> None: - """Set global OpenPype's environments.""" - import acre - - from openpype.settings import get_general_environments - - general_env = get_general_environments() - - # first resolve general environment because merge doesn't expect - # values to be list. - # TODO: switch to OpenPype environment functions - merged_env = acre.merge( - acre.compute(acre.parse(general_env), cleanup=False), - dict(os.environ) - ) - env = acre.compute( - merged_env, - cleanup=False - ) - os.environ.clear() - os.environ.update(env) - - # Hardcoded default values - os.environ["PYBLISH_GUI"] = "pyblish_pype" - # Change scale factor only if is not set - if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ: - os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - - -def set_addons_environments(): - """Set global environments for OpenPype modules. - - This requires to have OpenPype in `sys.path`. - """ - - import acre - from openpype.modules import ModulesManager - - modules_manager = ModulesManager() - - # Merge environments with current environments and update values - if module_envs := modules_manager.collect_global_environments(): - parsed_envs = acre.parse(module_envs) - env = acre.merge(parsed_envs, dict(os.environ)) - os.environ.clear() - os.environ.update(env) - - -def _connect_to_ayon_server(): - load_environments() - if not need_server_or_login(): - create_global_connection() - return - - if HEADLESS_MODE_ENABLED: - _print("!!! Cannot open v4 Login dialog in headless mode.") - _print(( - "!!! Please use `{}` to specify server address" - " and '{}' to specify user's token." - ).format(SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY)) - sys.exit(1) - - current_url = os.environ.get(SERVER_URL_ENV_KEY) - url, token, username = ask_to_login_ui(current_url, always_on_top=True) - if url is not None and token is not None: - confirm_server_login(url, token, username) - return - - if url is not None: - add_server(url, username) - - _print("!!! Login was not successful.") - sys.exit(0) - - -def _check_and_update_from_ayon_server(): - """Gets addon info from v4, compares with local folder and updates it. - - Raises: - RuntimeError - """ - - distribution = AyonDistribution() - bundle = None - bundle_name = None - try: - bundle = distribution.bundle_to_use - if bundle is not None: - bundle_name = bundle.name - except BundleNotFoundError as exc: - bundle_name = exc.bundle_name - - if bundle is None: - url = get_base_url() - if not HEADLESS_MODE_ENABLED: - show_missing_bundle_information(url, bundle_name) - - elif bundle_name: - _print(( - f"!!! Requested release bundle '{bundle_name}'" - " is not available on server." - )) - _print( - "!!! Check if selected release bundle" - f" is available on the server '{url}'." - ) - - else: - mode = "staging" if is_staging_enabled() else "production" - _print( - f"!!! No release bundle is set as {mode} on the AYON server." - ) - _print( - "!!! Make sure there is a release bundle set" - f" as \"{mode}\" on the AYON server '{url}'." - ) - sys.exit(1) - - distribution.distribute() - distribution.validate_distribution() - os.environ["AYON_BUNDLE_NAME"] = bundle_name - - python_paths = [ - path - for path in os.getenv("PYTHONPATH", "").split(os.pathsep) - if path - ] - - for path in distribution.get_sys_paths(): - sys.path.insert(0, path) - if path not in python_paths: - python_paths.append(path) - os.environ["PYTHONPATH"] = os.pathsep.join(python_paths) - - -def boot(): - """Bootstrap OpenPype.""" - - from openpype.version import __version__ - - # TODO load version - os.environ["OPENPYPE_VERSION"] = __version__ - os.environ["AYON_VERSION"] = __version__ - - _connect_to_ayon_server() - _check_and_update_from_ayon_server() - - # delete OpenPype module and it's submodules from cache so it is used from - # specific version - modules_to_del = [ - sys.modules.pop(module_name) - for module_name in tuple(sys.modules) - if module_name == "openpype" or module_name.startswith("openpype.") - ] - - for module_name in modules_to_del: - with contextlib.suppress(AttributeError, KeyError): - del sys.modules[module_name] - - -def main_cli(): - from openpype import cli - from openpype.version import __version__ - from openpype.lib import terminal as t - - _print(">>> loading environments ...") - _print(" - global AYON ...") - set_global_environments() - _print(" - for addons ...") - set_addons_environments() - - # print info when not running scripts defined in 'silent commands' - if not SKIP_HEADERS: - info = get_info(is_staging_enabled()) - info.insert(0, f">>> Using AYON from [ {AYON_ROOT} ]") - - t_width = 20 - with contextlib.suppress(ValueError, OSError): - t_width = os.get_terminal_size().columns - 2 - - _header = f"*** AYON [{__version__}] " - info.insert(0, _header + "-" * (t_width - len(_header))) - - for i in info: - t.echo(i) - - try: - cli.main(obj={}, prog_name="ayon") - except Exception: # noqa - exc_info = sys.exc_info() - _print("!!! AYON crashed:") - traceback.print_exception(*exc_info) - sys.exit(1) - - -def script_cli(): - """Run and execute script.""" - - filepath = os.path.abspath(sys.argv[1]) - - # Find '__main__.py' in directory - if os.path.isdir(filepath): - new_filepath = os.path.join(filepath, "__main__.py") - if not os.path.exists(new_filepath): - raise RuntimeError( - f"can't find '__main__' module in '{filepath}'") - filepath = new_filepath - - # Add parent dir to sys path - sys.path.insert(0, os.path.dirname(filepath)) - - # Read content and execute - with open(filepath, "r") as stream: - content = stream.read() - - exec(compile(content, filepath, "exec"), globals()) - - -def get_info(use_staging=None) -> list: - """Print additional information to console.""" - - inf = [] - if use_staging: - inf.append(("AYON variant", "staging")) - else: - inf.append(("AYON variant", "production")) - inf.append(("AYON bundle", os.getenv("AYON_BUNDLE"))) - - # NOTE add addons information - - maximum = max(len(i[0]) for i in inf) - formatted = [] - for info in inf: - padding = (maximum - len(info[0])) + 1 - formatted.append(f'... {info[0]}:{" " * padding}[ {info[1]} ]') - return formatted - - -def main(): - if not SKIP_BOOTSTRAP: - boot() - - args = list(sys.argv) - args.pop(0) - if args and os.path.exists(args[0]): - script_cli() - else: - main_cli() - - -if __name__ == "__main__": - main() diff --git a/common/ayon_common/__init__.py b/common/ayon_common/__init__.py deleted file mode 100644 index ddabb7da2f..0000000000 --- a/common/ayon_common/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from .utils import ( - IS_BUILT_APPLICATION, - is_staging_enabled, - get_local_site_id, - get_ayon_appdirs, - get_ayon_launch_args, -) - - -__all__ = ( - "IS_BUILT_APPLICATION", - "is_staging_enabled", - "get_local_site_id", - "get_ayon_appdirs", - "get_ayon_launch_args", -) diff --git a/common/ayon_common/connection/__init__.py b/common/ayon_common/connection/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/ayon_common/connection/credentials.py b/common/ayon_common/connection/credentials.py deleted file mode 100644 index 7f70cb7992..0000000000 --- a/common/ayon_common/connection/credentials.py +++ /dev/null @@ -1,511 +0,0 @@ -"""Handle credentials and connection to server for client application. - -Cache and store used server urls. Store/load API keys to/from keyring if -needed. Store metadata about used urls, usernames for the urls and when was -the connection with the username established. - -On bootstrap is created global connection with information about site and -client version. The connection object lives in 'ayon_api'. -""" - -import os -import json -import platform -import datetime -import contextlib -import subprocess -import tempfile -from typing import Optional, Union, Any - -import ayon_api - -from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY -from ayon_api.exceptions import UrlError -from ayon_api.utils import ( - validate_url, - is_token_valid, - logout_from_server, -) - -from ayon_common.utils import ( - get_ayon_appdirs, - get_local_site_id, - get_ayon_launch_args, - is_staging_enabled, -) - - -class ChangeUserResult: - def __init__( - self, logged_out, old_url, old_token, old_username, - new_url, new_token, new_username - ): - shutdown = logged_out - restart = new_url is not None and new_url != old_url - token_changed = new_token is not None and new_token != old_token - - self.logged_out = logged_out - self.old_url = old_url - self.old_token = old_token - self.old_username = old_username - self.new_url = new_url - self.new_token = new_token - self.new_username = new_username - - self.shutdown = shutdown - self.restart = restart - self.token_changed = token_changed - - -def _get_servers_path(): - return get_ayon_appdirs("used_servers.json") - - -def get_servers_info_data(): - """Metadata about used server on this machine. - - Store data about all used server urls, last used url and user username for - the url. Using this metadata we can remember which username was used per - url if token stored in keyring loose lifetime. - - Returns: - dict[str, Any]: Information about servers. - """ - - data = {} - servers_info_path = _get_servers_path() - if not os.path.exists(servers_info_path): - dirpath = os.path.dirname(servers_info_path) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - - return data - - with open(servers_info_path, "r") as stream: - with contextlib.suppress(BaseException): - data = json.load(stream) - return data - - -def add_server(url: str, username: str): - """Add server to server info metadata. - - This function will also mark the url as last used url on the machine so on - next launch will be used. - - Args: - url (str): Server url. - username (str): Name of user used to log in. - """ - - servers_info_path = _get_servers_path() - data = get_servers_info_data() - data["last_server"] = url - if "urls" not in data: - data["urls"] = {} - data["urls"][url] = { - "updated_dt": datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"), - "username": username, - } - - with open(servers_info_path, "w") as stream: - json.dump(data, stream) - - -def remove_server(url: str): - """Remove server url from servers information. - - This should be used on logout to completelly loose information about server - on the machine. - - Args: - url (str): Server url. - """ - - if not url: - return - - servers_info_path = _get_servers_path() - data = get_servers_info_data() - if data.get("last_server") == url: - data["last_server"] = None - - if "urls" in data: - data["urls"].pop(url, None) - - with open(servers_info_path, "w") as stream: - json.dump(data, stream) - - -def get_last_server( - data: Optional[dict[str, Any]] = None -) -> Union[str, None]: - """Last server used to log in on this machine. - - Args: - data (Optional[dict[str, Any]]): Prepared server information data. - - Returns: - Union[str, None]: Last used server url. - """ - - if data is None: - data = get_servers_info_data() - return data.get("last_server") - - -def get_last_username_by_url( - url: str, - data: Optional[dict[str, Any]] = None -) -> Union[str, None]: - """Get last username which was used for passed url. - - Args: - url (str): Server url. - data (Optional[dict[str, Any]]): Servers info. - - Returns: - Union[str, None]: Username. - """ - - if not url: - return None - - if data is None: - data = get_servers_info_data() - - if urls := data.get("urls"): - if url_info := urls.get(url): - return url_info.get("username") - return None - - -def get_last_server_with_username(): - """Receive last server and username used in last connection. - - Returns: - tuple[Union[str, None], Union[str, None]]: Url and username. - """ - - data = get_servers_info_data() - url = get_last_server(data) - username = get_last_username_by_url(url) - return url, username - - -class TokenKeyring: - # Fake username with hardcoded username - username_key = "username" - - def __init__(self, url): - try: - import keyring - - except Exception as exc: - raise NotImplementedError( - "Python module `keyring` is not available." - ) from exc - - # hack for cx_freeze and Windows keyring backend - if platform.system().lower() == "windows": - from keyring.backends import Windows - - keyring.set_keyring(Windows.WinVaultKeyring()) - - self._url = url - self._keyring_key = f"AYON/{url}" - - def get_value(self): - import keyring - - return keyring.get_password(self._keyring_key, self.username_key) - - def set_value(self, value): - import keyring - - if value is not None: - keyring.set_password(self._keyring_key, self.username_key, value) - return - - with contextlib.suppress(keyring.errors.PasswordDeleteError): - keyring.delete_password(self._keyring_key, self.username_key) - - -def load_token(url: str) -> Union[str, None]: - """Get token for url from keyring. - - Args: - url (str): Server url. - - Returns: - Union[str, None]: Token for passed url available in keyring. - """ - - return TokenKeyring(url).get_value() - - -def store_token(url: str, token: str): - """Store token by url to keyring. - - Args: - url (str): Server url. - token (str): User token to server. - """ - - TokenKeyring(url).set_value(token) - - -def ask_to_login_ui( - url: Optional[str] = None, - always_on_top: Optional[bool] = False -) -> tuple[str, str, str]: - """Ask user to login using UI. - - This should be used only when user is not yet logged in at all or available - credentials are invalid. To change credentials use 'change_user_ui' - function. - - Use a subprocess to show UI. - - Args: - url (Optional[str]): Server url that could be prefilled in UI. - always_on_top (Optional[bool]): Window will be drawn on top of - other windows. - - Returns: - tuple[str, str, str]: Url, user's token and username. - """ - - current_dir = os.path.dirname(os.path.abspath(__file__)) - ui_dir = os.path.join(current_dir, "ui") - - if url is None: - url = get_last_server() - username = get_last_username_by_url(url) - data = { - "url": url, - "username": username, - "always_on_top": always_on_top, - } - - with tempfile.NamedTemporaryFile( - mode="w", prefix="ayon_login", suffix=".json", delete=False - ) as tmp: - output = tmp.name - json.dump(data, tmp) - - code = subprocess.call( - get_ayon_launch_args(ui_dir, "--skip-bootstrap", output)) - if code != 0: - raise RuntimeError("Failed to show login UI") - - with open(output, "r") as stream: - data = json.load(stream) - os.remove(output) - return data["output"] - - -def change_user_ui() -> ChangeUserResult: - """Change user using UI. - - Show UI to user where he can change credentials or url. Output will contain - all information about old/new values of url, username, api key. If user - confirmed or declined values. - - Returns: - ChangeUserResult: Information about user change. - """ - - from .ui import change_user - - url, username = get_last_server_with_username() - token = load_token(url) - result = change_user(url, username, token) - new_url, new_token, new_username, logged_out = result - - output = ChangeUserResult( - logged_out, url, token, username, - new_url, new_token, new_username - ) - if output.logged_out: - logout(url, token) - - elif output.token_changed: - change_token( - output.new_url, - output.new_token, - output.new_username, - output.old_url - ) - return output - - -def change_token( - url: str, - token: str, - username: Optional[str] = None, - old_url: Optional[str] = None -): - """Change url and token in currently running session. - - Function can also change server url, in that case are previous credentials - NOT removed from cache. - - Args: - url (str): Url to server. - token (str): New token to be used for url connection. - username (Optional[str]): Username of logged user. - old_url (Optional[str]): Previous url. Value from 'get_last_server' - is used if not entered. - """ - - if old_url is None: - old_url = get_last_server() - if old_url and old_url == url: - remove_url_cache(old_url) - - # TODO check if ayon_api is already connected - add_server(url, username) - store_token(url, token) - ayon_api.change_token(url, token) - - -def remove_url_cache(url: str): - """Clear cache for server url. - - Args: - url (str): Server url which is removed from cache. - """ - - store_token(url, None) - - -def remove_token_cache(url: str, token: str): - """Remove token from local cache of url. - - Is skipped if cached token under the passed url is not the same - as passed token. - - Args: - url (str): Url to server. - token (str): Token to be removed from url cache. - """ - - if load_token(url) == token: - remove_url_cache(url) - - -def logout(url: str, token: str): - """Logout from server and throw token away. - - Args: - url (str): Url from which should be logged out. - token (str): Token which should be used to log out. - """ - - remove_server(url) - ayon_api.close_connection() - ayon_api.set_environments(None, None) - remove_token_cache(url, token) - logout_from_server(url, token) - - -def load_environments(): - """Load environments on startup. - - Handle environments needed for connection with server. Environments are - 'AYON_SERVER_URL' and 'AYON_API_KEY'. - - Server is looked up from environment. Already set environent is not - changed. If environemnt is not filled then last server stored in appdirs - is used. - - Token is skipped if url is not available. Otherwise, is also checked from - env and if is not available then uses 'load_token' to try to get token - based on server url. - """ - - server_url = os.environ.get(SERVER_URL_ENV_KEY) - if not server_url: - server_url = get_last_server() - if not server_url: - return - os.environ[SERVER_URL_ENV_KEY] = server_url - - if not os.environ.get(SERVER_API_ENV_KEY): - if token := load_token(server_url): - os.environ[SERVER_API_ENV_KEY] = token - - -def set_environments(url: str, token: str): - """Change url and token environemnts in currently running process. - - Args: - url (str): New server url. - token (str): User's token. - """ - - ayon_api.set_environments(url, token) - - -def create_global_connection(): - """Create global connection with site id and client version. - - Make sure the global connection in 'ayon_api' have entered site id and - client version. - - Set default settings variant to use based on 'is_staging_enabled'. - """ - - ayon_api.create_connection( - get_local_site_id(), os.environ.get("AYON_VERSION") - ) - ayon_api.set_default_settings_variant( - "staging" if is_staging_enabled() else "production" - ) - - -def need_server_or_login() -> bool: - """Check if server url or login to the server are needed. - - It is recommended to call 'load_environments' on startup before this check. - But in some cases this function could be called after startup. - - Returns: - bool: 'True' if server and token are available. Otherwise 'False'. - """ - - server_url = os.environ.get(SERVER_URL_ENV_KEY) - if not server_url: - return True - - try: - server_url = validate_url(server_url) - except UrlError: - return True - - token = os.environ.get(SERVER_API_ENV_KEY) - if token: - return not is_token_valid(server_url, token) - - token = load_token(server_url) - if token: - return not is_token_valid(server_url, token) - return True - - -def confirm_server_login(url, token, username): - """Confirm login of user and do necessary stepts to apply changes. - - This should not be used on "change" of user but on first login. - - Args: - url (str): Server url where user authenticated. - token (str): API token used for authentication to server. - username (Union[str, None]): Username related to API token. - """ - - add_server(url, username) - store_token(url, token) - set_environments(url, token) - create_global_connection() diff --git a/common/ayon_common/connection/ui/__init__.py b/common/ayon_common/connection/ui/__init__.py deleted file mode 100644 index 96e573df0d..0000000000 --- a/common/ayon_common/connection/ui/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .login_window import ( - ServerLoginWindow, - ask_to_login, - change_user, -) - - -__all__ = ( - "ServerLoginWindow", - "ask_to_login", - "change_user", -) diff --git a/common/ayon_common/connection/ui/__main__.py b/common/ayon_common/connection/ui/__main__.py deleted file mode 100644 index 719b2b8ef5..0000000000 --- a/common/ayon_common/connection/ui/__main__.py +++ /dev/null @@ -1,23 +0,0 @@ -import sys -import json - -from ayon_common.connection.ui.login_window import ask_to_login - - -def main(output_path): - with open(output_path, "r") as stream: - data = json.load(stream) - - url = data.get("url") - username = data.get("username") - always_on_top = data.get("always_on_top", False) - out_url, out_token, out_username = ask_to_login( - url, username, always_on_top=always_on_top) - - data["output"] = [out_url, out_token, out_username] - with open(output_path, "w") as stream: - json.dump(data, stream) - - -if __name__ == "__main__": - main(sys.argv[-1]) diff --git a/common/ayon_common/connection/ui/login_window.py b/common/ayon_common/connection/ui/login_window.py deleted file mode 100644 index 94c239852e..0000000000 --- a/common/ayon_common/connection/ui/login_window.py +++ /dev/null @@ -1,710 +0,0 @@ -import traceback - -from qtpy import QtWidgets, QtCore, QtGui - -from ayon_api.exceptions import UrlError -from ayon_api.utils import validate_url, login_to_server - -from ayon_common.resources import ( - get_resource_path, - get_icon_path, - load_stylesheet, -) -from ayon_common.ui_utils import set_style_property, get_qt_app - -from .widgets import ( - PressHoverButton, - PlaceholderLineEdit, -) - - -class LogoutConfirmDialog(QtWidgets.QDialog): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle("Logout confirmation") - - message_widget = QtWidgets.QWidget(self) - - message_label = QtWidgets.QLabel( - ( - "You are going to logout. This action will close this" - " application and will invalidate your login." - " All other applications launched with this login won't be" - " able to use it anymore.

" - "You can cancel logout and only change server and user login" - " in login dialog.

" - "Press OK to confirm logout." - ), - message_widget - ) - message_label.setWordWrap(True) - - message_layout = QtWidgets.QHBoxLayout(message_widget) - message_layout.setContentsMargins(0, 0, 0, 0) - message_layout.addWidget(message_label, 1) - - sep_frame = QtWidgets.QFrame(self) - sep_frame.setObjectName("Separator") - sep_frame.setMinimumHeight(2) - sep_frame.setMaximumHeight(2) - - footer_widget = QtWidgets.QWidget(self) - - cancel_btn = QtWidgets.QPushButton("Cancel", footer_widget) - confirm_btn = QtWidgets.QPushButton("OK", footer_widget) - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addStretch(1) - footer_layout.addWidget(cancel_btn, 0) - footer_layout.addWidget(confirm_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(message_widget, 0) - main_layout.addStretch(1) - main_layout.addWidget(sep_frame, 0) - main_layout.addWidget(footer_widget, 0) - - cancel_btn.clicked.connect(self._on_cancel_click) - confirm_btn.clicked.connect(self._on_confirm_click) - - self._cancel_btn = cancel_btn - self._confirm_btn = confirm_btn - self._result = False - - def showEvent(self, event): - super().showEvent(event) - self._match_btns_sizes() - - def resizeEvent(self, event): - super().resizeEvent(event) - self._match_btns_sizes() - - def _match_btns_sizes(self): - width = max( - self._cancel_btn.sizeHint().width(), - self._confirm_btn.sizeHint().width() - ) - self._cancel_btn.setMinimumWidth(width) - self._confirm_btn.setMinimumWidth(width) - - def _on_cancel_click(self): - self._result = False - self.reject() - - def _on_confirm_click(self): - self._result = True - self.accept() - - def get_result(self): - return self._result - - -class ServerLoginWindow(QtWidgets.QDialog): - default_width = 410 - default_height = 170 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - icon_path = get_icon_path() - icon = QtGui.QIcon(icon_path) - self.setWindowIcon(icon) - self.setWindowTitle("Login to server") - - edit_icon_path = get_resource_path("edit.png") - edit_icon = QtGui.QIcon(edit_icon_path) - - # --- URL page --- - login_widget = QtWidgets.QWidget(self) - - user_cred_widget = QtWidgets.QWidget(login_widget) - - url_label = QtWidgets.QLabel("URL:", user_cred_widget) - - url_widget = QtWidgets.QWidget(user_cred_widget) - - url_input = PlaceholderLineEdit(url_widget) - url_input.setPlaceholderText("< https://ayon.server.com >") - - url_preview = QtWidgets.QLineEdit(url_widget) - url_preview.setReadOnly(True) - url_preview.setObjectName("LikeDisabledInput") - - url_edit_btn = PressHoverButton(user_cred_widget) - url_edit_btn.setIcon(edit_icon) - url_edit_btn.setObjectName("PasswordBtn") - - url_layout = QtWidgets.QHBoxLayout(url_widget) - url_layout.setContentsMargins(0, 0, 0, 0) - url_layout.addWidget(url_input, 1) - url_layout.addWidget(url_preview, 1) - - # --- URL separator --- - url_cred_sep = QtWidgets.QFrame(self) - url_cred_sep.setObjectName("Separator") - url_cred_sep.setMinimumHeight(2) - url_cred_sep.setMaximumHeight(2) - - # --- Login page --- - username_label = QtWidgets.QLabel("Username:", user_cred_widget) - - username_widget = QtWidgets.QWidget(user_cred_widget) - - username_input = PlaceholderLineEdit(username_widget) - username_input.setPlaceholderText("< Artist >") - - username_preview = QtWidgets.QLineEdit(username_widget) - username_preview.setReadOnly(True) - username_preview.setObjectName("LikeDisabledInput") - - username_edit_btn = PressHoverButton(user_cred_widget) - username_edit_btn.setIcon(edit_icon) - username_edit_btn.setObjectName("PasswordBtn") - - username_layout = QtWidgets.QHBoxLayout(username_widget) - username_layout.setContentsMargins(0, 0, 0, 0) - username_layout.addWidget(username_input, 1) - username_layout.addWidget(username_preview, 1) - - password_label = QtWidgets.QLabel("Password:", user_cred_widget) - password_input = PlaceholderLineEdit(user_cred_widget) - password_input.setPlaceholderText("< *********** >") - password_input.setEchoMode(PlaceholderLineEdit.Password) - - api_label = QtWidgets.QLabel("API key:", user_cred_widget) - api_preview = QtWidgets.QLineEdit(user_cred_widget) - api_preview.setReadOnly(True) - api_preview.setObjectName("LikeDisabledInput") - - show_password_icon_path = get_resource_path("eye.png") - show_password_icon = QtGui.QIcon(show_password_icon_path) - show_password_btn = PressHoverButton(user_cred_widget) - show_password_btn.setObjectName("PasswordBtn") - show_password_btn.setIcon(show_password_icon) - show_password_btn.setFocusPolicy(QtCore.Qt.ClickFocus) - - cred_msg_sep = QtWidgets.QFrame(self) - cred_msg_sep.setObjectName("Separator") - cred_msg_sep.setMinimumHeight(2) - cred_msg_sep.setMaximumHeight(2) - - # --- Credentials inputs --- - user_cred_layout = QtWidgets.QGridLayout(user_cred_widget) - user_cred_layout.setContentsMargins(0, 0, 0, 0) - row = 0 - - user_cred_layout.addWidget(url_label, row, 0, 1, 1) - user_cred_layout.addWidget(url_widget, row, 1, 1, 1) - user_cred_layout.addWidget(url_edit_btn, row, 2, 1, 1) - row += 1 - - user_cred_layout.addWidget(url_cred_sep, row, 0, 1, 3) - row += 1 - - user_cred_layout.addWidget(username_label, row, 0, 1, 1) - user_cred_layout.addWidget(username_widget, row, 1, 1, 1) - user_cred_layout.addWidget(username_edit_btn, row, 2, 2, 1) - row += 1 - - user_cred_layout.addWidget(api_label, row, 0, 1, 1) - user_cred_layout.addWidget(api_preview, row, 1, 1, 1) - row += 1 - - user_cred_layout.addWidget(password_label, row, 0, 1, 1) - user_cred_layout.addWidget(password_input, row, 1, 1, 1) - user_cred_layout.addWidget(show_password_btn, row, 2, 1, 1) - row += 1 - - user_cred_layout.addWidget(cred_msg_sep, row, 0, 1, 3) - row += 1 - - user_cred_layout.setColumnStretch(0, 0) - user_cred_layout.setColumnStretch(1, 1) - user_cred_layout.setColumnStretch(2, 0) - - login_layout = QtWidgets.QVBoxLayout(login_widget) - login_layout.setContentsMargins(0, 0, 0, 0) - login_layout.addWidget(user_cred_widget, 1) - - # --- Messages --- - # Messages for users (e.g. invalid url etc.) - message_label = QtWidgets.QLabel(self) - message_label.setWordWrap(True) - message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - - footer_widget = QtWidgets.QWidget(self) - logout_btn = QtWidgets.QPushButton("Logout", footer_widget) - user_message = QtWidgets.QLabel(footer_widget) - login_btn = QtWidgets.QPushButton("Login", footer_widget) - confirm_btn = QtWidgets.QPushButton("Confirm", footer_widget) - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addWidget(logout_btn, 0) - footer_layout.addWidget(user_message, 1) - footer_layout.addWidget(login_btn, 0) - footer_layout.addWidget(confirm_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(login_widget, 0) - main_layout.addWidget(message_label, 0) - main_layout.addStretch(1) - main_layout.addWidget(footer_widget, 0) - - url_input.textChanged.connect(self._on_url_change) - url_input.returnPressed.connect(self._on_url_enter_press) - username_input.textChanged.connect(self._on_user_change) - username_input.returnPressed.connect(self._on_username_enter_press) - password_input.returnPressed.connect(self._on_password_enter_press) - show_password_btn.change_state.connect(self._on_show_password) - url_edit_btn.clicked.connect(self._on_url_edit_click) - username_edit_btn.clicked.connect(self._on_username_edit_click) - logout_btn.clicked.connect(self._on_logout_click) - login_btn.clicked.connect(self._on_login_click) - confirm_btn.clicked.connect(self._on_login_click) - - self._message_label = message_label - - self._url_widget = url_widget - self._url_input = url_input - self._url_preview = url_preview - self._url_edit_btn = url_edit_btn - - self._login_widget = login_widget - - self._user_cred_widget = user_cred_widget - self._username_input = username_input - self._username_preview = username_preview - self._username_edit_btn = username_edit_btn - - self._password_label = password_label - self._password_input = password_input - self._show_password_btn = show_password_btn - self._api_label = api_label - self._api_preview = api_preview - - self._logout_btn = logout_btn - self._user_message = user_message - self._login_btn = login_btn - self._confirm_btn = confirm_btn - - self._url_is_valid = None - self._credentials_are_valid = None - self._result = (None, None, None, False) - self._first_show = True - - self._allow_logout = False - self._logged_in = False - self._url_edit_mode = False - self._username_edit_mode = False - - def set_allow_logout(self, allow_logout): - if allow_logout is self._allow_logout: - return - self._allow_logout = allow_logout - - self._update_states_by_edit_mode() - - def _set_logged_in(self, logged_in): - if logged_in is self._logged_in: - return - self._logged_in = logged_in - - self._update_states_by_edit_mode() - - def _set_url_edit_mode(self, edit_mode): - if self._url_edit_mode is not edit_mode: - self._url_edit_mode = edit_mode - self._update_states_by_edit_mode() - - def _set_username_edit_mode(self, edit_mode): - if self._username_edit_mode is not edit_mode: - self._username_edit_mode = edit_mode - self._update_states_by_edit_mode() - - def _get_url_user_edit(self): - url_edit = True - if self._logged_in and not self._url_edit_mode: - url_edit = False - user_edit = url_edit - if not user_edit and self._logged_in and self._username_edit_mode: - user_edit = True - return url_edit, user_edit - - def _update_states_by_edit_mode(self): - url_edit, user_edit = self._get_url_user_edit() - - self._url_preview.setVisible(not url_edit) - self._url_input.setVisible(url_edit) - self._url_edit_btn.setVisible(self._allow_logout and not url_edit) - - self._username_preview.setVisible(not user_edit) - self._username_input.setVisible(user_edit) - self._username_edit_btn.setVisible( - self._allow_logout and not user_edit - ) - - self._api_preview.setVisible(not user_edit) - self._api_label.setVisible(not user_edit) - - self._password_label.setVisible(user_edit) - self._show_password_btn.setVisible(user_edit) - self._password_input.setVisible(user_edit) - - self._logout_btn.setVisible(self._allow_logout and self._logged_in) - self._login_btn.setVisible(not self._allow_logout) - self._confirm_btn.setVisible(self._allow_logout) - self._update_login_btn_state(url_edit, user_edit) - - def _update_login_btn_state(self, url_edit=None, user_edit=None, url=None): - if url_edit is None: - url_edit, user_edit = self._get_url_user_edit() - - if url is None: - url = self._url_input.text() - - enabled = bool(url) and (url_edit or user_edit) - - self._login_btn.setEnabled(enabled) - self._confirm_btn.setEnabled(enabled) - - def showEvent(self, event): - super().showEvent(event) - if self._first_show: - self._first_show = False - self._on_first_show() - - def _on_first_show(self): - self.setStyleSheet(load_stylesheet()) - self.resize(self.default_width, self.default_height) - self._center_window() - if self._allow_logout is None: - self.set_allow_logout(False) - - self._update_states_by_edit_mode() - if not self._url_input.text(): - widget = self._url_input - elif not self._username_input.text(): - widget = self._username_input - else: - widget = self._password_input - - self._set_input_focus(widget) - - def result(self): - """Result url and token or login. - - Returns: - Union[Tuple[str, str], Tuple[None, None]]: Url and token used for - login if was successful otherwise are both set to None. - """ - return self._result - - def _center_window(self): - """Move window to center of screen.""" - - if hasattr(QtWidgets.QApplication, "desktop"): - desktop = QtWidgets.QApplication.desktop() - screen_idx = desktop.screenNumber(self) - screen_geo = desktop.screenGeometry(screen_idx) - else: - screen = self.screen() - screen_geo = screen.geometry() - - geo = self.frameGeometry() - geo.moveCenter(screen_geo.center()) - if geo.y() < screen_geo.y(): - geo.setY(screen_geo.y()) - self.move(geo.topLeft()) - - def _on_url_change(self, text): - self._update_login_btn_state(url=text) - self._set_url_valid(None) - self._set_credentials_valid(None) - self._url_preview.setText(text) - - def _set_url_valid(self, valid): - if valid is self._url_is_valid: - return - - self._url_is_valid = valid - self._set_input_valid_state(self._url_input, valid) - - def _set_credentials_valid(self, valid): - if self._credentials_are_valid is valid: - return - - self._credentials_are_valid = valid - self._set_input_valid_state(self._username_input, valid) - self._set_input_valid_state(self._password_input, valid) - - def _on_url_enter_press(self): - self._set_input_focus(self._username_input) - - def _on_user_change(self, username): - self._username_preview.setText(username) - - def _on_username_enter_press(self): - self._set_input_focus(self._password_input) - - def _on_password_enter_press(self): - self._login() - - def _on_show_password(self, show_password): - if show_password: - placeholder_text = "< MySecret124 >" - echo_mode = QtWidgets.QLineEdit.Normal - else: - placeholder_text = "< *********** >" - echo_mode = QtWidgets.QLineEdit.Password - - self._password_input.setEchoMode(echo_mode) - self._password_input.setPlaceholderText(placeholder_text) - - def _on_username_edit_click(self): - self._username_edit_mode = True - self._update_states_by_edit_mode() - - def _on_url_edit_click(self): - self._url_edit_mode = True - self._update_states_by_edit_mode() - - def _on_logout_click(self): - dialog = LogoutConfirmDialog(self) - dialog.exec_() - if dialog.get_result(): - self._result = (None, None, None, True) - self.accept() - - def _on_login_click(self): - self._login() - - def _validate_url(self): - """Use url from input to connect and change window state on success. - - Todos: - Threaded check. - """ - - url = self._url_input.text() - valid_url = None - try: - valid_url = validate_url(url) - - except UrlError as exc: - parts = [f"{exc.title}"] - parts.extend(f"- {hint}" for hint in exc.hints) - self._set_message("
".join(parts)) - - except KeyboardInterrupt: - # Reraise KeyboardInterrupt error - raise - - except BaseException: - self._set_unexpected_error() - return - - if valid_url is None: - return False - - self._url_input.setText(valid_url) - return True - - def _login(self): - if ( - not self._login_btn.isEnabled() - and not self._confirm_btn.isEnabled() - ): - return - - if not self._url_is_valid: - self._set_url_valid(self._validate_url()) - - if not self._url_is_valid: - self._set_input_focus(self._url_input) - self._set_credentials_valid(None) - return - - self._clear_message() - - url = self._url_input.text() - username = self._username_input.text() - password = self._password_input.text() - try: - token = login_to_server(url, username, password) - except BaseException: - self._set_unexpected_error() - return - - if token is not None: - self._result = (url, token, username, False) - self.accept() - return - - self._set_credentials_valid(False) - message_lines = ["Invalid credentials"] - if not username.strip(): - message_lines.append("- Username is not filled") - - if not password.strip(): - message_lines.append("- Password is not filled") - - if username and password: - message_lines.append("- Check your credentials") - - self._set_message("
".join(message_lines)) - self._set_input_focus(self._username_input) - - def _set_input_focus(self, widget): - widget.setFocus(QtCore.Qt.MouseFocusReason) - - def _set_input_valid_state(self, widget, valid): - state = "" - if valid is True: - state = "valid" - elif valid is False: - state = "invalid" - set_style_property(widget, "state", state) - - def _set_message(self, message): - self._message_label.setText(message) - - def _clear_message(self): - self._message_label.setText("") - - def _set_unexpected_error(self): - # TODO add traceback somewhere - # - maybe a button to show or copy? - traceback.print_exc() - lines = [ - "Unexpected error happened", - "- Can be caused by wrong url (leading elsewhere)" - ] - self._set_message("
".join(lines)) - - def set_url(self, url): - self._url_preview.setText(url) - self._url_input.setText(url) - self._validate_url() - - def set_username(self, username): - self._username_preview.setText(username) - self._username_input.setText(username) - - def _set_api_key(self, api_key): - if not api_key or len(api_key) < 3: - self._api_preview.setText(api_key or "") - return - - api_key_len = len(api_key) - offset = 6 - if api_key_len < offset: - offset = api_key_len // 2 - api_key = api_key[:offset] + "." * (api_key_len - offset) - - self._api_preview.setText(api_key) - - def set_logged_in( - self, - logged_in, - url=None, - username=None, - api_key=None, - allow_logout=None - ): - if url is not None: - self.set_url(url) - - if username is not None: - self.set_username(username) - - if api_key: - self._set_api_key(api_key) - - if logged_in and allow_logout is None: - allow_logout = True - - self._set_logged_in(logged_in) - - if allow_logout: - self.set_allow_logout(True) - elif allow_logout is False: - self.set_allow_logout(False) - - -def ask_to_login(url=None, username=None, always_on_top=False): - """Ask user to login using Qt dialog. - - Function creates new QApplication if is not created yet. - - Args: - url (Optional[str]): Server url that will be prefilled in dialog. - username (Optional[str]): Username that will be prefilled in dialog. - always_on_top (Optional[bool]): Window will be drawn on top of - other windows. - - Returns: - tuple[str, str, str]: Returns Url, user's token and username. Url can - be changed during dialog lifetime that's why the url is returned. - """ - - app_instance = get_qt_app() - - window = ServerLoginWindow() - if always_on_top: - window.setWindowFlags( - window.windowFlags() - | QtCore.Qt.WindowStaysOnTopHint - ) - - if url: - window.set_url(url) - - if username: - window.set_username(username) - - if not app_instance.startingUp(): - window.exec_() - else: - window.open() - app_instance.exec_() - result = window.result() - out_url, out_token, out_username, _ = result - return out_url, out_token, out_username - - -def change_user(url, username, api_key, always_on_top=False): - """Ask user to login using Qt dialog. - - Function creates new QApplication if is not created yet. - - Args: - url (str): Server url that will be prefilled in dialog. - username (str): Username that will be prefilled in dialog. - api_key (str): API key that will be prefilled in dialog. - always_on_top (Optional[bool]): Window will be drawn on top of - other windows. - - Returns: - Tuple[str, str]: Returns Url and user's token. Url can be changed - during dialog lifetime that's why the url is returned. - """ - - app_instance = get_qt_app() - window = ServerLoginWindow() - if always_on_top: - window.setWindowFlags( - window.windowFlags() - | QtCore.Qt.WindowStaysOnTopHint - ) - window.set_logged_in(True, url, username, api_key) - - if not app_instance.startingUp(): - window.exec_() - else: - window.open() - # This can become main Qt loop. Maybe should live elsewhere - app_instance.exec_() - return window.result() diff --git a/common/ayon_common/connection/ui/widgets.py b/common/ayon_common/connection/ui/widgets.py deleted file mode 100644 index 78b73e056d..0000000000 --- a/common/ayon_common/connection/ui/widgets.py +++ /dev/null @@ -1,47 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - - -class PressHoverButton(QtWidgets.QPushButton): - """Keep track about mouse press/release and enter/leave.""" - - _mouse_pressed = False - _mouse_hovered = False - change_state = QtCore.Signal(bool) - - def mousePressEvent(self, event): - self._mouse_pressed = True - self._mouse_hovered = True - self.change_state.emit(self._mouse_hovered) - super(PressHoverButton, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - self._mouse_pressed = False - self._mouse_hovered = False - self.change_state.emit(self._mouse_hovered) - super(PressHoverButton, self).mouseReleaseEvent(event) - - def mouseMoveEvent(self, event): - mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) - under_mouse = self.rect().contains(mouse_pos) - if under_mouse != self._mouse_hovered: - self._mouse_hovered = under_mouse - self.change_state.emit(self._mouse_hovered) - - super(PressHoverButton, self).mouseMoveEvent(event) - - -class PlaceholderLineEdit(QtWidgets.QLineEdit): - """Set placeholder color of QLineEdit in Qt 5.12 and higher.""" - - def __init__(self, *args, **kwargs): - super(PlaceholderLineEdit, self).__init__(*args, **kwargs) - # Change placeholder palette color - if hasattr(QtGui.QPalette, "PlaceholderText"): - filter_palette = self.palette() - color = QtGui.QColor("#D3D8DE") - color.setAlpha(67) - filter_palette.setColor( - QtGui.QPalette.PlaceholderText, - color - ) - self.setPalette(filter_palette) diff --git a/common/ayon_common/distribution/README.md b/common/ayon_common/distribution/README.md deleted file mode 100644 index f1c34ba722..0000000000 --- a/common/ayon_common/distribution/README.md +++ /dev/null @@ -1,18 +0,0 @@ -Addon distribution tool ------------------------- - -Code in this folder is backend portion of Addon distribution logic for v4 server. - -Each host, module will be separate Addon in the future. Each v4 server could run different set of Addons. - -Client (running on artist machine) will in the first step ask v4 for list of enabled addons. -(It expects list of json documents matching to `addon_distribution.py:AddonInfo` object.) -Next it will compare presence of enabled addon version in local folder. In the case of missing version of -an addon, client will use information in the addon to download (from http/shared local disk/git) zip file -and unzip it. - -Required part of addon distribution will be sharing of dependencies (python libraries, utilities) which is not part of this folder. - -Location of this folder might change in the future as it will be required for a clint to add this folder to sys.path reliably. - -This code needs to be independent on Openpype code as much as possible! diff --git a/common/ayon_common/distribution/__init__.py b/common/ayon_common/distribution/__init__.py deleted file mode 100644 index e3c0f0e161..0000000000 --- a/common/ayon_common/distribution/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .control import AyonDistribution, BundleNotFoundError -from .utils import show_missing_bundle_information - - -__all__ = ( - "AyonDistribution", - "BundleNotFoundError", - "show_missing_bundle_information", -) diff --git a/common/ayon_common/distribution/control.py b/common/ayon_common/distribution/control.py deleted file mode 100644 index 95c221d753..0000000000 --- a/common/ayon_common/distribution/control.py +++ /dev/null @@ -1,1116 +0,0 @@ -import os -import sys -import json -import traceback -import collections -import datetime -import logging -import shutil -import threading -import platform -import attr -from enum import Enum - -import ayon_api - -from ayon_common.utils import is_staging_enabled - -from .utils import ( - get_addons_dir, - get_dependencies_dir, -) -from .downloaders import get_default_download_factory -from .data_structures import ( - AddonInfo, - DependencyItem, - Bundle, -) - -NOT_SET = type("UNKNOWN", (), {"__bool__": lambda: False})() - - -class BundleNotFoundError(Exception): - """Bundle name is defined but is not available on server. - - Args: - bundle_name (str): Name of bundle that was not found. - """ - - def __init__(self, bundle_name): - self.bundle_name = bundle_name - super().__init__( - f"Bundle '{bundle_name}' is not available on server" - ) - - -class UpdateState(Enum): - UNKNOWN = "unknown" - UPDATED = "udated" - OUTDATED = "outdated" - UPDATE_FAILED = "failed" - MISS_SOURCE_FILES = "miss_source_files" - - -class DistributeTransferProgress: - """Progress of single source item in 'DistributionItem'. - - The item is to keep track of single source item. - """ - - def __init__(self): - self._transfer_progress = ayon_api.TransferProgress() - self._started = False - self._failed = False - self._fail_reason = None - self._unzip_started = False - self._unzip_finished = False - self._hash_check_started = False - self._hash_check_finished = False - - def set_started(self): - """Call when source distribution starts.""" - - self._started = True - - def set_failed(self, reason): - """Set source distribution as failed. - - Args: - reason (str): Error message why the transfer failed. - """ - - self._failed = True - self._fail_reason = reason - - def set_hash_check_started(self): - """Call just before hash check starts.""" - - self._hash_check_started = True - - def set_hash_check_finished(self): - """Call just after hash check finishes.""" - - self._hash_check_finished = True - - def set_unzip_started(self): - """Call just before unzip starts.""" - - self._unzip_started = True - - def set_unzip_finished(self): - """Call just after unzip finishes.""" - - self._unzip_finished = True - - @property - def is_running(self): - """Source distribution is in progress. - - Returns: - bool: Transfer is in progress. - """ - - return bool( - self._started - and not self._failed - and not self._hash_check_finished - ) - - @property - def transfer_progress(self): - """Source file 'download' progress tracker. - - Returns: - ayon_api.TransferProgress.: Content download progress. - """ - - return self._transfer_progress - - @property - def started(self): - return self._started - - @property - def hash_check_started(self): - return self._hash_check_started - - @property - def hash_check_finished(self): - return self._has_check_finished - - @property - def unzip_started(self): - return self._unzip_started - - @property - def unzip_finished(self): - return self._unzip_finished - - @property - def failed(self): - return self._failed or self._transfer_progress.failed - - @property - def fail_reason(self): - return self._fail_reason or self._transfer_progress.fail_reason - - -class DistributionItem: - """Distribution item with sources and target directories. - - Distribution item can be an addon or dependency package. Distribution item - can be already distributed and don't need any progression. The item keeps - track of the progress. The reason is to be able to use the distribution - items as source data for UI without implementing the same logic. - - Distribution is "state" based. Distribution can be 'UPDATED' or 'OUTDATED' - at the initialization. If item is 'UPDATED' the distribution is skipped - and 'OUTDATED' will trigger the distribution process. - - Because the distribution may have multiple sources each source has own - progress item. - - Args: - state (UpdateState): Initial state (UpdateState.UPDATED or - UpdateState.OUTDATED). - unzip_dirpath (str): Path to directory where zip is downloaded. - download_dirpath (str): Path to directory where file is unzipped. - file_hash (str): Hash of file for validation. - factory (DownloadFactory): Downloaders factory object. - sources (List[SourceInfo]): Possible sources to receive the - distribution item. - downloader_data (Dict[str, Any]): More information for downloaders. - item_label (str): Label used in log outputs (and in UI). - logger (logging.Logger): Logger object. - """ - - def __init__( - self, - state, - unzip_dirpath, - download_dirpath, - file_hash, - factory, - sources, - downloader_data, - item_label, - logger=None, - ): - if logger is None: - logger = logging.getLogger(self.__class__.__name__) - self.log = logger - self.state = state - self.unzip_dirpath = unzip_dirpath - self.download_dirpath = download_dirpath - self.file_hash = file_hash - self.factory = factory - self.sources = [ - (source, DistributeTransferProgress()) - for source in sources - ] - self.downloader_data = downloader_data - self.item_label = item_label - - self._need_distribution = state != UpdateState.UPDATED - self._current_source_progress = None - self._used_source_progress = None - self._used_source = None - self._dist_started = False - self._dist_finished = False - - self._error_msg = None - self._error_detail = None - - @property - def need_distribution(self): - """Need distribution based on initial state. - - Returns: - bool: Need distribution. - """ - - return self._need_distribution - - @property - def current_source_progress(self): - """Currently processed source progress object. - - Returns: - Union[DistributeTransferProgress, None]: Transfer progress or None. - """ - - return self._current_source_progress - - @property - def used_source_progress(self): - """Transfer progress that successfully distributed the item. - - Returns: - Union[DistributeTransferProgress, None]: Transfer progress or None. - """ - - return self._used_source_progress - - @property - def used_source(self): - """Data of source item. - - Returns: - Union[Dict[str, Any], None]: SourceInfo data or None. - """ - - return self._used_source - - @property - def error_message(self): - """Reason why distribution item failed. - - Returns: - Union[str, None]: Error message. - """ - - return self._error_msg - - @property - def error_detail(self): - """Detailed reason why distribution item failed. - - Returns: - Union[str, None]: Detailed information (maybe traceback). - """ - - return self._error_detail - - def _distribute(self): - if not self.sources: - message = ( - f"{self.item_label}: Don't have" - " any sources to download from." - ) - self.log.error(message) - self._error_msg = message - self.state = UpdateState.MISS_SOURCE_FILES - return - - download_dirpath = self.download_dirpath - unzip_dirpath = self.unzip_dirpath - for source, source_progress in self.sources: - self._current_source_progress = source_progress - source_progress.set_started() - - # Remove directory if exists - if os.path.isdir(unzip_dirpath): - self.log.debug(f"Cleaning {unzip_dirpath}") - shutil.rmtree(unzip_dirpath) - - # Create directory - os.makedirs(unzip_dirpath) - if not os.path.isdir(download_dirpath): - os.makedirs(download_dirpath) - - try: - downloader = self.factory.get_downloader(source.type) - except Exception: - message = f"Unknown downloader {source.type}" - source_progress.set_failed(message) - self.log.warning(message, exc_info=True) - continue - - source_data = attr.asdict(source) - cleanup_args = ( - source_data, - download_dirpath, - self.downloader_data - ) - - try: - zip_filepath = downloader.download( - source_data, - download_dirpath, - self.downloader_data, - source_progress.transfer_progress, - ) - except Exception: - message = "Failed to download source" - source_progress.set_failed(message) - self.log.warning( - f"{self.item_label}: {message}", - exc_info=True - ) - downloader.cleanup(*cleanup_args) - continue - - source_progress.set_hash_check_started() - try: - downloader.check_hash(zip_filepath, self.file_hash) - except Exception: - message = "File hash does not match" - source_progress.set_failed(message) - self.log.warning( - f"{self.item_label}: {message}", - exc_info=True - ) - downloader.cleanup(*cleanup_args) - continue - - source_progress.set_hash_check_finished() - source_progress.set_unzip_started() - try: - downloader.unzip(zip_filepath, unzip_dirpath) - except Exception: - message = "Couldn't unzip source file" - source_progress.set_failed(message) - self.log.warning( - f"{self.item_label}: {message}", - exc_info=True - ) - downloader.cleanup(*cleanup_args) - continue - - source_progress.set_unzip_finished() - downloader.cleanup(*cleanup_args) - self.state = UpdateState.UPDATED - self._used_source = source_data - break - - last_progress = self._current_source_progress - self._current_source_progress = None - if self.state == UpdateState.UPDATED: - self._used_source_progress = last_progress - self.log.info(f"{self.item_label}: Distributed") - return - - self.log.error(f"{self.item_label}: Failed to distribute") - self._error_msg = "Failed to receive or install source files" - - def distribute(self): - """Execute distribution logic.""" - - if not self.need_distribution or self._dist_started: - return - - self._dist_started = True - try: - if self.state == UpdateState.OUTDATED: - self._distribute() - - except Exception as exc: - self.state = UpdateState.UPDATE_FAILED - self._error_msg = str(exc) - self._error_detail = "".join( - traceback.format_exception(*sys.exc_info()) - ) - self.log.error( - f"{self.item_label}: Distibution filed", - exc_info=True - ) - - finally: - self._dist_finished = True - if self.state == UpdateState.OUTDATED: - self.state = UpdateState.UPDATE_FAILED - self._error_msg = "Distribution failed" - - if ( - self.state != UpdateState.UPDATED - and self.unzip_dirpath - and os.path.isdir(self.unzip_dirpath) - ): - self.log.debug(f"Cleaning {self.unzip_dirpath}") - shutil.rmtree(self.unzip_dirpath) - - -class AyonDistribution: - """Distribution control. - - Receive information from server what addons and dependency packages - should be available locally and prepare/validate their distribution. - - Arguments are available for testing of the class. - - Args: - addon_dirpath (Optional[str]): Where addons will be stored. - dependency_dirpath (Optional[str]): Where dependencies will be stored. - dist_factory (Optional[DownloadFactory]): Factory which cares about - downloading of items based on source type. - addons_info (Optional[list[dict[str, Any]]): List of prepared - addons' info. - dependency_packages_info (Optional[list[dict[str, Any]]): Info - about packages from server. - bundles_info (Optional[Dict[str, Any]]): Info about - bundles. - bundle_name (Optional[str]): Name of bundle to use. If not passed - an environment variable 'AYON_BUNDLE_NAME' is checked for value. - When both are not available the bundle is defined by 'use_staging' - value. - use_staging (Optional[bool]): Use staging versions of an addon. - If not passed, 'is_staging_enabled' is used as default value. - """ - - def __init__( - self, - addon_dirpath=None, - dependency_dirpath=None, - dist_factory=None, - addons_info=NOT_SET, - dependency_packages_info=NOT_SET, - bundles_info=NOT_SET, - bundle_name=NOT_SET, - use_staging=None - ): - self._log = None - - self._dist_started = False - self._dist_finished = False - - self._addons_dirpath = addon_dirpath or get_addons_dir() - self._dependency_dirpath = dependency_dirpath or get_dependencies_dir() - self._dist_factory = ( - dist_factory or get_default_download_factory() - ) - - if bundle_name is NOT_SET: - bundle_name = os.environ.get("AYON_BUNDLE_NAME", NOT_SET) - - # Raw addons data from server - self._addons_info = addons_info - # Prepared data as Addon objects - self._addon_items = NOT_SET - # Distrubtion items of addons - # - only those addons and versions that should be distributed - self._addon_dist_items = NOT_SET - - # Raw dependency packages data from server - self._dependency_packages_info = dependency_packages_info - # Prepared dependency packages as objects - self._dependency_packages_items = NOT_SET - # Dependency package item that should be used - self._dependency_package_item = NOT_SET - # Distribution item of dependency package - self._dependency_dist_item = NOT_SET - - # Raw bundles data from server - self._bundles_info = bundles_info - # Bundles as objects - self._bundle_items = NOT_SET - - # Bundle that should be used in production - self._production_bundle = NOT_SET - # Bundle that should be used in staging - self._staging_bundle = NOT_SET - # Boolean that defines if staging bundle should be used - self._use_staging = use_staging - - # Specific bundle name should be used - self._bundle_name = bundle_name - # Final bundle that will be used - self._bundle = NOT_SET - - @property - def use_staging(self): - """Staging version of a bundle should be used. - - This value is completely ignored if specific bundle name should - be used. - - Returns: - bool: True if staging version should be used. - """ - - if self._use_staging is None: - self._use_staging = is_staging_enabled() - return self._use_staging - - @property - def log(self): - """Helper to access logger. - - Returns: - logging.Logger: Logger instance. - """ - if self._log is None: - self._log = logging.getLogger(self.__class__.__name__) - return self._log - - @property - def bundles_info(self): - """ - - Returns: - dict[str, dict[str, Any]]: Bundles information from server. - """ - - if self._bundles_info is NOT_SET: - self._bundles_info = ayon_api.get_bundles() - return self._bundles_info - - @property - def bundle_items(self): - """ - - Returns: - list[Bundle]: List of bundles info. - """ - - if self._bundle_items is NOT_SET: - self._bundle_items = [ - Bundle.from_dict(info) - for info in self.bundles_info["bundles"] - ] - return self._bundle_items - - def _prepare_production_staging_bundles(self): - production_bundle = None - staging_bundle = None - for bundle in self.bundle_items: - if bundle.is_production: - production_bundle = bundle - if bundle.is_staging: - staging_bundle = bundle - self._production_bundle = production_bundle - self._staging_bundle = staging_bundle - - @property - def production_bundle(self): - """ - Returns: - Union[Bundle, None]: Bundle that should be used in production. - """ - - if self._production_bundle is NOT_SET: - self._prepare_production_staging_bundles() - return self._production_bundle - - @property - def staging_bundle(self): - """ - Returns: - Union[Bundle, None]: Bundle that should be used in staging. - """ - - if self._staging_bundle is NOT_SET: - self._prepare_production_staging_bundles() - return self._staging_bundle - - @property - def bundle_to_use(self): - """Bundle that will be used for distribution. - - Bundle that should be used can be affected by 'bundle_name' - or 'use_staging'. - - Returns: - Union[Bundle, None]: Bundle that will be used for distribution - or None. - - Raises: - BundleNotFoundError: When bundle name to use is defined - but is not available on server. - """ - - if self._bundle is NOT_SET: - if self._bundle_name is not NOT_SET: - bundle = next( - ( - bundle - for bundle in self.bundle_items - if bundle.name == self._bundle_name - ), - None - ) - if bundle is None: - raise BundleNotFoundError(self._bundle_name) - - self._bundle = bundle - elif self.use_staging: - self._bundle = self.staging_bundle - else: - self._bundle = self.production_bundle - return self._bundle - - @property - def bundle_name_to_use(self): - bundle = self.bundle_to_use - return None if bundle is None else bundle.name - - @property - def addons_info(self): - """Server information about available addons. - - Returns: - Dict[str, dict[str, Any]: Addon info by addon name. - """ - - if self._addons_info is NOT_SET: - server_info = ayon_api.get_addons_info(details=True) - self._addons_info = server_info["addons"] - return self._addons_info - - @property - def addon_items(self): - """Information about available addons on server. - - Addons may require distribution of files. For those addons will be - created 'DistributionItem' handling distribution itself. - - Returns: - Dict[str, AddonInfo]: Addon info object by addon name. - """ - - if self._addon_items is NOT_SET: - addons_info = {} - for addon in self.addons_info: - addon_info = AddonInfo.from_dict(addon) - addons_info[addon_info.name] = addon_info - self._addon_items = addons_info - return self._addon_items - - @property - def dependency_packages_info(self): - """Server information about available dependency packages. - - Notes: - For testing purposes it is possible to pass dependency packages - information to '__init__'. - - Returns: - list[dict[str, Any]]: Dependency packages information. - """ - - if self._dependency_packages_info is NOT_SET: - self._dependency_packages_info = ( - ayon_api.get_dependency_packages())["packages"] - return self._dependency_packages_info - - @property - def dependency_packages_items(self): - """Dependency packages as objects. - - Returns: - dict[str, DependencyItem]: Dependency packages as objects by name. - """ - - if self._dependency_packages_items is NOT_SET: - dependenc_package_items = {} - for item in self.dependency_packages_info: - item = DependencyItem.from_dict(item) - dependenc_package_items[item.name] = item - self._dependency_packages_items = dependenc_package_items - return self._dependency_packages_items - - @property - def dependency_package_item(self): - """Dependency package item that should be used by bundle. - - Returns: - Union[None, Dict[str, Any]]: None if bundle does not have - specified dependency package. - """ - - if self._dependency_package_item is NOT_SET: - dependency_package_item = None - bundle = self.bundle_to_use - if bundle is not None: - package_name = bundle.dependency_packages.get( - platform.system().lower() - ) - dependency_package_item = self.dependency_packages_items.get( - package_name) - self._dependency_package_item = dependency_package_item - return self._dependency_package_item - - def _prepare_current_addon_dist_items(self): - addons_metadata = self.get_addons_metadata() - output = [] - addon_versions = {} - bundle = self.bundle_to_use - if bundle is not None: - addon_versions = bundle.addon_versions - for addon_name, addon_item in self.addon_items.items(): - addon_version = addon_versions.get(addon_name) - # Addon is not in bundle -> Skip - if addon_version is None: - continue - - addon_version_item = addon_item.versions.get(addon_version) - # Addon version is not available in addons info - # - TODO handle this case (raise error, skip, store, report, ...) - if addon_version_item is None: - print( - f"Version '{addon_version}' of addon '{addon_name}'" - " is not available on server." - ) - continue - - if not addon_version_item.require_distribution: - continue - full_name = addon_version_item.full_name - addon_dest = os.path.join(self._addons_dirpath, full_name) - self.log.debug(f"Checking {full_name} in {addon_dest}") - addon_in_metadata = ( - addon_name in addons_metadata - and addon_version_item.version in addons_metadata[addon_name] - ) - if addon_in_metadata and os.path.isdir(addon_dest): - self.log.debug( - f"Addon version folder {addon_dest} already exists." - ) - state = UpdateState.UPDATED - - else: - state = UpdateState.OUTDATED - - downloader_data = { - "type": "addon", - "name": addon_name, - "version": addon_version - } - - dist_item = DistributionItem( - state, - addon_dest, - addon_dest, - addon_version_item.hash, - self._dist_factory, - list(addon_version_item.sources), - downloader_data, - full_name, - self.log - ) - output.append({ - "dist_item": dist_item, - "addon_name": addon_name, - "addon_version": addon_version, - "addon_item": addon_item, - "addon_version_item": addon_version_item, - }) - return output - - def _prepare_dependency_progress(self): - package = self.dependency_package_item - if package is None: - return None - - metadata = self.get_dependency_metadata() - downloader_data = { - "type": "dependency_package", - "name": package.name, - "platform": package.platform_name - } - zip_dir = package_dir = os.path.join( - self._dependency_dirpath, package.name - ) - self.log.debug(f"Checking {package.name} in {package_dir}") - - if not os.path.isdir(package_dir) or package.name not in metadata: - state = UpdateState.OUTDATED - else: - state = UpdateState.UPDATED - - return DistributionItem( - state, - zip_dir, - package_dir, - package.checksum, - self._dist_factory, - package.sources, - downloader_data, - package.name, - self.log, - ) - - def get_addon_dist_items(self): - """Addon distribution items. - - These items describe source files required by addon to be available on - machine. Each item may have 0-n source information from where can be - obtained. If file is already available it's state will be 'UPDATED'. - - Example output: - [ - { - "dist_item": DistributionItem, - "addon_name": str, - "addon_version": str, - "addon_item": AddonInfo, - "addon_version_item": AddonVersionInfo - }, { - ... - } - ] - - Returns: - list[dict[str, Any]]: Distribution items with addon version item. - """ - - if self._addon_dist_items is NOT_SET: - self._addon_dist_items = ( - self._prepare_current_addon_dist_items()) - return self._addon_dist_items - - def get_dependency_dist_item(self): - """Dependency package distribution item. - - Item describe source files required by server to be available on - machine. Item may have 0-n source information from where can be - obtained. If file is already available it's state will be 'UPDATED'. - - 'None' is returned if server does not have defined any dependency - package. - - Returns: - Union[None, DistributionItem]: Dependency item or None if server - does not have specified any dependency package. - """ - - if self._dependency_dist_item is NOT_SET: - self._dependency_dist_item = self._prepare_dependency_progress() - return self._dependency_dist_item - - def get_dependency_metadata_filepath(self): - """Path to distribution metadata file. - - Metadata contain information about distributed packages, used source, - expected file hash and time when file was distributed. - - Returns: - str: Path to a file where dependency package metadata are stored. - """ - - return os.path.join(self._dependency_dirpath, "dependency.json") - - def get_addons_metadata_filepath(self): - """Path to addons metadata file. - - Metadata contain information about distributed addons, used sources, - expected file hashes and time when files were distributed. - - Returns: - str: Path to a file where addons metadata are stored. - """ - - return os.path.join(self._addons_dirpath, "addons.json") - - def read_metadata_file(self, filepath, default_value=None): - """Read json file from path. - - Method creates the file when does not exist with default value. - - Args: - filepath (str): Path to json file. - default_value (Union[Dict[str, Any], List[Any], None]): Default - value if the file is not available (or valid). - - Returns: - Union[Dict[str, Any], List[Any]]: Value from file. - """ - - if default_value is None: - default_value = {} - - if not os.path.exists(filepath): - return default_value - - try: - with open(filepath, "r") as stream: - data = json.load(stream) - except ValueError: - data = default_value - return data - - def save_metadata_file(self, filepath, data): - """Store data to json file. - - Method creates the file when does not exist. - - Args: - filepath (str): Path to json file. - data (Union[Dict[str, Any], List[Any]]): Data to store into file. - """ - - if not os.path.exists(filepath): - dirpath = os.path.dirname(filepath) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - with open(filepath, "w") as stream: - json.dump(data, stream, indent=4) - - def get_dependency_metadata(self): - filepath = self.get_dependency_metadata_filepath() - return self.read_metadata_file(filepath, {}) - - def update_dependency_metadata(self, package_name, data): - dependency_metadata = self.get_dependency_metadata() - dependency_metadata[package_name] = data - filepath = self.get_dependency_metadata_filepath() - self.save_metadata_file(filepath, dependency_metadata) - - def get_addons_metadata(self): - filepath = self.get_addons_metadata_filepath() - return self.read_metadata_file(filepath, {}) - - def update_addons_metadata(self, addons_information): - if not addons_information: - return - addons_metadata = self.get_addons_metadata() - for addon_name, version_value in addons_information.items(): - if addon_name not in addons_metadata: - addons_metadata[addon_name] = {} - for addon_version, version_data in version_value.items(): - addons_metadata[addon_name][addon_version] = version_data - - filepath = self.get_addons_metadata_filepath() - self.save_metadata_file(filepath, addons_metadata) - - def finish_distribution(self): - """Store metadata about distributed items.""" - - self._dist_finished = True - stored_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - dependency_dist_item = self.get_dependency_dist_item() - if ( - dependency_dist_item is not None - and dependency_dist_item.need_distribution - and dependency_dist_item.state == UpdateState.UPDATED - ): - package = self.dependency_package - source = dependency_dist_item.used_source - if source is not None: - data = { - "source": source, - "file_hash": dependency_dist_item.file_hash, - "distributed_dt": stored_time - } - self.update_dependency_metadata(package.name, data) - - addons_info = {} - for item in self.get_addon_dist_items(): - dist_item = item["dist_item"] - if ( - not dist_item.need_distribution - or dist_item.state != UpdateState.UPDATED - ): - continue - - source_data = dist_item.used_source - if not source_data: - continue - - addon_name = item["addon_name"] - addon_version = item["addon_version"] - addons_info.setdefault(addon_name, {}) - addons_info[addon_name][addon_version] = { - "source": source_data, - "file_hash": dist_item.file_hash, - "distributed_dt": stored_time - } - - self.update_addons_metadata(addons_info) - - def get_all_distribution_items(self): - """Distribution items required by server. - - Items contain dependency package item and all addons that are enabled - and have distribution requirements. - - Items can be already available on machine. - - Returns: - List[DistributionItem]: Distribution items required by server. - """ - - output = [ - item["dist_item"] - for item in self.get_addon_dist_items() - ] - dependency_dist_item = self.get_dependency_dist_item() - if dependency_dist_item is not None: - output.insert(0, dependency_dist_item) - - return output - - def distribute(self, threaded=False): - """Distribute all missing items. - - Method will try to distribute all items that are required by server. - - This method does not handle failed items. To validate the result call - 'validate_distribution' when this method finishes. - - Args: - threaded (bool): Distribute items in threads. - """ - - if self._dist_started: - raise RuntimeError("Distribution already started") - self._dist_started = True - threads = collections.deque() - for item in self.get_all_distribution_items(): - if threaded: - threads.append(threading.Thread(target=item.distribute)) - else: - item.distribute() - - while threads: - thread = threads.popleft() - if thread.is_alive(): - threads.append(thread) - else: - thread.join() - - self.finish_distribution() - - def validate_distribution(self): - """Check if all required distribution items are distributed. - - Raises: - RuntimeError: Any of items is not available. - """ - - invalid = [] - dependency_package = self.get_dependency_dist_item() - if ( - dependency_package is not None - and dependency_package.state != UpdateState.UPDATED - ): - invalid.append("Dependency package") - - for item in self.get_addon_dist_items(): - dist_item = item["dist_item"] - if dist_item.state != UpdateState.UPDATED: - invalid.append(item["addon_name"]) - - if not invalid: - return - - raise RuntimeError("Failed to distribute {}".format( - ", ".join([f'"{item}"' for item in invalid]) - )) - - def get_sys_paths(self): - """Get all paths to python packages that should be added to python. - - These paths lead to addon directories and python dependencies in - dependency package. - - Todos: - Add dependency package directory to output. ATM is not structure of - dependency package 100% defined. - - Returns: - List[str]: Paths that should be added to 'sys.path' and - 'PYTHONPATH'. - """ - - output = [] - for item in self.get_all_distribution_items(): - if item.state != UpdateState.UPDATED: - continue - unzip_dirpath = item.unzip_dirpath - if unzip_dirpath and os.path.exists(unzip_dirpath): - output.append(unzip_dirpath) - return output - - -def cli(*args): - raise NotImplementedError diff --git a/common/ayon_common/distribution/data_structures.py b/common/ayon_common/distribution/data_structures.py deleted file mode 100644 index aa93d4ed71..0000000000 --- a/common/ayon_common/distribution/data_structures.py +++ /dev/null @@ -1,265 +0,0 @@ -import attr -from enum import Enum - - -class UrlType(Enum): - HTTP = "http" - GIT = "git" - FILESYSTEM = "filesystem" - SERVER = "server" - - -@attr.s -class MultiPlatformValue(object): - windows = attr.ib(default=None) - linux = attr.ib(default=None) - darwin = attr.ib(default=None) - - -@attr.s -class SourceInfo(object): - type = attr.ib() - - -@attr.s -class LocalSourceInfo(SourceInfo): - path = attr.ib(default=attr.Factory(MultiPlatformValue)) - - -@attr.s -class WebSourceInfo(SourceInfo): - url = attr.ib(default=None) - headers = attr.ib(default=None) - filename = attr.ib(default=None) - - -@attr.s -class ServerSourceInfo(SourceInfo): - filename = attr.ib(default=None) - path = attr.ib(default=None) - - -def convert_source(source): - """Create source object from data information. - - Args: - source (Dict[str, any]): Information about source. - - Returns: - Union[None, SourceInfo]: Object with source information if type is - known. - """ - - source_type = source.get("type") - if not source_type: - return None - - if source_type == UrlType.FILESYSTEM.value: - return LocalSourceInfo( - type=source_type, - path=source["path"] - ) - - if source_type == UrlType.HTTP.value: - url = source["path"] - return WebSourceInfo( - type=source_type, - url=url, - headers=source.get("headers"), - filename=source.get("filename") - ) - - if source_type == UrlType.SERVER.value: - return ServerSourceInfo( - type=source_type, - filename=source.get("filename"), - path=source.get("path") - ) - - -def prepare_sources(src_sources): - sources = [] - unknown_sources = [] - for source in (src_sources or []): - dependency_source = convert_source(source) - if dependency_source is not None: - sources.append(dependency_source) - else: - print(f"Unknown source {source.get('type')}") - unknown_sources.append(source) - return sources, unknown_sources - - -@attr.s -class VersionData(object): - version_data = attr.ib(default=None) - - -@attr.s -class AddonVersionInfo(object): - version = attr.ib() - full_name = attr.ib() - title = attr.ib(default=None) - require_distribution = attr.ib(default=False) - sources = attr.ib(default=attr.Factory(list)) - unknown_sources = attr.ib(default=attr.Factory(list)) - hash = attr.ib(default=None) - - @classmethod - def from_dict( - cls, addon_name, addon_title, addon_version, version_data - ): - """Addon version info. - - Args: - addon_name (str): Name of addon. - addon_title (str): Title of addon. - addon_version (str): Version of addon. - version_data (dict[str, Any]): Addon version information from - server. - - Returns: - AddonVersionInfo: Addon version info. - """ - - full_name = f"{addon_name}_{addon_version}" - title = f"{addon_title} {addon_version}" - - source_info = version_data.get("clientSourceInfo") - require_distribution = source_info is not None - sources, unknown_sources = prepare_sources(source_info) - - return cls( - version=addon_version, - full_name=full_name, - require_distribution=require_distribution, - sources=sources, - unknown_sources=unknown_sources, - hash=version_data.get("hash"), - title=title - ) - - -@attr.s -class AddonInfo(object): - """Object matching json payload from Server""" - name = attr.ib() - versions = attr.ib(default=attr.Factory(dict)) - title = attr.ib(default=None) - description = attr.ib(default=None) - license = attr.ib(default=None) - authors = attr.ib(default=None) - - @classmethod - def from_dict(cls, data): - """Addon info by available versions. - - Args: - data (dict[str, Any]): Addon information from server. Should - contain information about every version under 'versions'. - - Returns: - AddonInfo: Addon info with available versions. - """ - - # server payload contains info about all versions - addon_name = data["name"] - title = data.get("title") or addon_name - - src_versions = data.get("versions") or {} - dst_versions = { - addon_version: AddonVersionInfo.from_dict( - addon_name, title, addon_version, version_data - ) - for addon_version, version_data in src_versions.items() - } - return cls( - name=addon_name, - versions=dst_versions, - description=data.get("description"), - title=data.get("title") or addon_name, - license=data.get("license"), - authors=data.get("authors") - ) - - -@attr.s -class DependencyItem(object): - """Object matching payload from Server about single dependency package""" - name = attr.ib() - platform_name = attr.ib() - checksum = attr.ib() - sources = attr.ib(default=attr.Factory(list)) - unknown_sources = attr.ib(default=attr.Factory(list)) - source_addons = attr.ib(default=attr.Factory(dict)) - python_modules = attr.ib(default=attr.Factory(dict)) - - @classmethod - def from_dict(cls, package): - src_sources = package.get("sources") or [] - for source in src_sources: - if source.get("type") == "server" and not source.get("filename"): - source["filename"] = package["filename"] - sources, unknown_sources = prepare_sources(src_sources) - return cls( - name=package["filename"], - platform_name=package["platform"], - sources=sources, - unknown_sources=unknown_sources, - checksum=package["checksum"], - source_addons=package["sourceAddons"], - python_modules=package["pythonModules"] - ) - - -@attr.s -class Installer: - version = attr.ib() - filename = attr.ib() - platform_name = attr.ib() - size = attr.ib() - checksum = attr.ib() - python_version = attr.ib() - python_modules = attr.ib() - sources = attr.ib(default=attr.Factory(list)) - unknown_sources = attr.ib(default=attr.Factory(list)) - - @classmethod - def from_dict(cls, installer_info): - sources, unknown_sources = prepare_sources( - installer_info.get("sources")) - - return cls( - version=installer_info["version"], - filename=installer_info["filename"], - platform_name=installer_info["platform"], - size=installer_info["size"], - sources=sources, - unknown_sources=unknown_sources, - checksum=installer_info["checksum"], - python_version=installer_info["pythonVersion"], - python_modules=installer_info["pythonModules"] - ) - - -@attr.s -class Bundle: - """Class representing bundle information.""" - - name = attr.ib() - installer_version = attr.ib() - addon_versions = attr.ib(default=attr.Factory(dict)) - dependency_packages = attr.ib(default=attr.Factory(dict)) - is_production = attr.ib(default=False) - is_staging = attr.ib(default=False) - - @classmethod - def from_dict(cls, data): - return cls( - name=data["name"], - installer_version=data.get("installerVersion"), - addon_versions=data.get("addons", {}), - dependency_packages=data.get("dependencyPackages", {}), - is_production=data["isProduction"], - is_staging=data["isStaging"], - ) diff --git a/common/ayon_common/distribution/downloaders.py b/common/ayon_common/distribution/downloaders.py deleted file mode 100644 index 23280176c3..0000000000 --- a/common/ayon_common/distribution/downloaders.py +++ /dev/null @@ -1,250 +0,0 @@ -import os -import logging -import platform -from abc import ABCMeta, abstractmethod - -import ayon_api - -from .file_handler import RemoteFileHandler -from .data_structures import UrlType - - -class SourceDownloader(metaclass=ABCMeta): - """Abstract class for source downloader.""" - - log = logging.getLogger(__name__) - - @classmethod - @abstractmethod - def download(cls, source, destination_dir, data, transfer_progress): - """Returns url of downloaded addon zip file. - - Tranfer progress can be ignored, in that case file transfer won't - be shown as 0-100% but as 'running'. First step should be to set - destination content size and then add transferred chunk sizes. - - Args: - source (dict): {type:"http", "url":"https://} ...} - destination_dir (str): local folder to unzip - data (dict): More information about download content. Always have - 'type' key in. - transfer_progress (ayon_api.TransferProgress): Progress of - transferred (copy/download) content. - - Returns: - (str) local path to addon zip file - """ - - pass - - @classmethod - @abstractmethod - def cleanup(cls, source, destination_dir, data): - """Cleanup files when distribution finishes or crashes. - - Cleanup e.g. temporary files (downloaded zip) or other related stuff - to downloader. - """ - - pass - - @classmethod - def check_hash(cls, addon_path, addon_hash, hash_type="sha256"): - """Compares 'hash' of downloaded 'addon_url' file. - - Args: - addon_path (str): Local path to addon file. - addon_hash (str): Hash of downloaded file. - hash_type (str): Type of hash. - - Raises: - ValueError if hashes doesn't match - """ - - if not os.path.exists(addon_path): - raise ValueError(f"{addon_path} doesn't exist.") - if not RemoteFileHandler.check_integrity( - addon_path, addon_hash, hash_type=hash_type - ): - raise ValueError(f"{addon_path} doesn't match expected hash.") - - @classmethod - def unzip(cls, addon_zip_path, destination_dir): - """Unzips local 'addon_zip_path' to 'destination'. - - Args: - addon_zip_path (str): local path to addon zip file - destination_dir (str): local folder to unzip - """ - - RemoteFileHandler.unzip(addon_zip_path, destination_dir) - os.remove(addon_zip_path) - - -class OSDownloader(SourceDownloader): - """Downloader using files from file drive.""" - - @classmethod - def download(cls, source, destination_dir, data, transfer_progress): - # OS doesn't need to download, unzip directly - addon_url = source["path"].get(platform.system().lower()) - if not os.path.exists(addon_url): - raise ValueError(f"{addon_url} is not accessible") - return addon_url - - @classmethod - def cleanup(cls, source, destination_dir, data): - # Nothing to do - download does not copy anything - pass - - -class HTTPDownloader(SourceDownloader): - """Downloader using http or https protocol.""" - - CHUNK_SIZE = 100000 - - @staticmethod - def get_filename(source): - source_url = source["url"] - filename = source.get("filename") - if not filename: - filename = os.path.basename(source_url) - basename, ext = os.path.splitext(filename) - allowed_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS) - if ext.lower().lstrip(".") not in allowed_exts: - filename = f"{basename}.zip" - return filename - - @classmethod - def download(cls, source, destination_dir, data, transfer_progress): - source_url = source["url"] - cls.log.debug(f"Downloading {source_url} to {destination_dir}") - headers = source.get("headers") - filename = cls.get_filename(source) - - # TODO use transfer progress - RemoteFileHandler.download_url( - source_url, - destination_dir, - filename, - headers=headers - ) - - return os.path.join(destination_dir, filename) - - @classmethod - def cleanup(cls, source, destination_dir, data): - filename = cls.get_filename(source) - filepath = os.path.join(destination_dir, filename) - if os.path.exists(filepath) and os.path.isfile(filepath): - os.remove(filepath) - - -class AyonServerDownloader(SourceDownloader): - """Downloads static resource file from AYON Server. - - Expects filled env var AYON_SERVER_URL. - """ - - CHUNK_SIZE = 8192 - - @classmethod - def download(cls, source, destination_dir, data, transfer_progress): - path = source["path"] - filename = source["filename"] - if path and not filename: - filename = path.split("/")[-1] - - cls.log.debug(f"Downloading {filename} to {destination_dir}") - - _, ext = os.path.splitext(filename) - ext = ext.lower().lstrip(".") - valid_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS) - if ext not in valid_exts: - raise ValueError(( - f"Invalid file extension \"{ext}\"." - f" Expected {', '.join(valid_exts)}" - )) - - if path: - filepath = os.path.join(destination_dir, filename) - return ayon_api.download_file( - path, - filepath, - chunk_size=cls.CHUNK_SIZE, - progress=transfer_progress - ) - - # dst_filepath = os.path.join(destination_dir, filename) - if data["type"] == "dependency_package": - return ayon_api.download_dependency_package( - data["name"], - destination_dir, - filename, - platform_name=data["platform"], - chunk_size=cls.CHUNK_SIZE, - progress=transfer_progress - ) - - if data["type"] == "addon": - return ayon_api.download_addon_private_file( - data["name"], - data["version"], - filename, - destination_dir, - chunk_size=cls.CHUNK_SIZE, - progress=transfer_progress - ) - - raise ValueError(f"Unknown type to download \"{data['type']}\"") - - @classmethod - def cleanup(cls, source, destination_dir, data): - filename = source["filename"] - filepath = os.path.join(destination_dir, filename) - if os.path.exists(filepath) and os.path.isfile(filepath): - os.remove(filepath) - - -class DownloadFactory: - """Factory for downloaders.""" - - def __init__(self): - self._downloaders = {} - - def register_format(self, downloader_type, downloader): - """Register downloader for download type. - - Args: - downloader_type (UrlType): Type of source. - downloader (SourceDownloader): Downloader which cares about - download, hash check and unzipping. - """ - - self._downloaders[downloader_type.value] = downloader - - def get_downloader(self, downloader_type): - """Registered downloader for type. - - Args: - downloader_type (UrlType): Type of source. - - Returns: - SourceDownloader: Downloader object which should care about file - distribution. - - Raises: - ValueError: If type does not have registered downloader. - """ - - if downloader := self._downloaders.get(downloader_type): - return downloader() - raise ValueError(f"{downloader_type} not implemented") - - -def get_default_download_factory(): - download_factory = DownloadFactory() - download_factory.register_format(UrlType.FILESYSTEM, OSDownloader) - download_factory.register_format(UrlType.HTTP, HTTPDownloader) - download_factory.register_format(UrlType.SERVER, AyonServerDownloader) - return download_factory diff --git a/common/ayon_common/distribution/file_handler.py b/common/ayon_common/distribution/file_handler.py deleted file mode 100644 index 07f6962c98..0000000000 --- a/common/ayon_common/distribution/file_handler.py +++ /dev/null @@ -1,289 +0,0 @@ -import os -import re -import urllib -from urllib.parse import urlparse -import urllib.request -import urllib.error -import itertools -import hashlib -import tarfile -import zipfile - -import requests - -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" - } - - @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""): - md5.update(chunk) - return md5.hexdigest() - - @staticmethod - def check_md5(fpath, md5, **kwargs): - return md5 == RemoteFileHandler.calculate_md5(fpath, **kwargs) - - @staticmethod - def calculate_sha256(fpath): - """Calculate sha256 for content of the file. - - Args: - fpath (str): Path to file. - - Returns: - str: hex encoded sha256 - - """ - h = hashlib.sha256() - b = bytearray(128 * 1024) - mv = memoryview(b) - with open(fpath, "rb", buffering=0) as f: - for n in iter(lambda: f.readinto(mv), 0): - h.update(mv[:n]) - return h.hexdigest() - - @staticmethod - def check_sha256(fpath, sha256, **kwargs): - return sha256 == RemoteFileHandler.calculate_sha256(fpath, **kwargs) - - @staticmethod - def check_integrity(fpath, hash_value=None, hash_type=None): - if not os.path.isfile(fpath): - return False - if hash_value is None: - return True - if not hash_type: - raise ValueError("Provide hash type, md5 or sha256") - 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, - max_redirect_hops=3, - headers=None - ): - """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 - 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) - fpath = os.path.join(root, filename) - - os.makedirs(root, exist_ok=True) - - # expand redirect chain if needed - 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) - - # download the file - try: - 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 - - 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 - ): - """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. - """ - # Based on https://stackoverflow.com/questions/38511444/python-download-files-from-google-drive-using-url # noqa - - url = "https://docs.google.com/uc?export=download" - - root = os.path.expanduser(root) - if not filename: - filename = file_id - fpath = os.path.join(root, filename) - - os.makedirs(root, exist_ok=True) - - 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) - token = RemoteFileHandler._get_confirm_token(response) - - if token: - params = {"id": file_id, "confirm": token} - response = session.get(url, params=params, stream=True) - - response_content_generator = response.iter_content(32768) - first_chunk = None - while not first_chunk: # filter out keep-alive new chunks - first_chunk = next(response_content_generator) - - if RemoteFileHandler._quota_exceeded(first_chunk): - msg = ( - f"The daily quota of the file {filename} is exceeded and " - f"it can't be downloaded. This is a limitation of " - f"Google Drive and can only be overcome by trying " - f"again later." - ) - raise RuntimeError(msg) - - RemoteFileHandler._save_response_content( - itertools.chain((first_chunk, ), - response_content_generator), fpath) - response.close() - - @staticmethod - def unzip(path, destination_path=None): - if not destination_path: - destination_path = os.path.dirname(path) - - _, archive_type = os.path.splitext(path) - archive_type = archive_type.lstrip(".") - - 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" - ]: - 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:*" - try: - tar_file = tarfile.open(path, tar_type) - except tarfile.ReadError: - raise SystemExit("corrupted archive") - tar_file.extractall(destination_path) - tar_file.close() - - @staticmethod - 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=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, headers=None): - initial_url = url - 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=final_headers) - ) as response: - if response.url == url or response.url is None: - return url - - return response.url - else: - raise RecursionError( - f"Request to {initial_url} exceeded {max_hops} redirects. " - f"The last redirect points to {url}." - ) - - @staticmethod - def _get_confirm_token(response): - for key, value in response.cookies.items(): - if key.startswith("download_warning"): - return value - - # handle antivirus warning for big zips - found = re.search("(confirm=)([^&.+])", response.text) - if found: - return found.groups()[1] - - return None - - @staticmethod - def _save_response_content( - response_gen, destination, - ): - with open(destination, "wb") as f: - for chunk in response_gen: - if chunk: # filter out keep-alive new chunks - f.write(chunk) - - @staticmethod - def _quota_exceeded(first_chunk): - try: - return "Google Drive - Quota exceeded" in first_chunk.decode() - except UnicodeDecodeError: - return False - - @staticmethod - def _get_google_drive_file_id(url): - parts = urlparse(url) - - if re.match(r"(drive|docs)[.]google[.]com", parts.netloc) is None: - return None - - match = re.match(r"/file/d/(?P[^/]*)", parts.path) - if match is None: - return None - - return match.group("id") diff --git a/common/ayon_common/distribution/tests/test_addon_distributtion.py b/common/ayon_common/distribution/tests/test_addon_distributtion.py deleted file mode 100644 index 3e7bd1bc6a..0000000000 --- a/common/ayon_common/distribution/tests/test_addon_distributtion.py +++ /dev/null @@ -1,248 +0,0 @@ -import os -import sys -import copy -import tempfile - - -import attr -import pytest - -current_dir = os.path.dirname(os.path.abspath(__file__)) -root_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..", "..")) -sys.path.append(root_dir) - -from common.ayon_common.distribution.downloaders import ( - DownloadFactory, - OSDownloader, - HTTPDownloader, -) -from common.ayon_common.distribution.control import ( - AyonDistribution, - UpdateState, -) -from common.ayon_common.distribution.data_structures import ( - AddonInfo, - UrlType, -) - - -@pytest.fixture -def download_factory(): - addon_downloader = DownloadFactory() - addon_downloader.register_format(UrlType.FILESYSTEM, OSDownloader) - addon_downloader.register_format(UrlType.HTTP, HTTPDownloader) - - yield addon_downloader - - -@pytest.fixture -def http_downloader(download_factory): - yield download_factory.get_downloader(UrlType.HTTP.value) - - -@pytest.fixture -def temp_folder(): - yield tempfile.mkdtemp(prefix="ayon_test_") - - -@pytest.fixture -def sample_bundles(): - yield { - "bundles": [ - { - "name": "TestBundle", - "createdAt": "2023-06-29T00:00:00.0+00:00", - "installerVersion": None, - "addons": { - "slack": "1.0.0" - }, - "dependencyPackages": {}, - "isProduction": True, - "isStaging": False - } - ], - "productionBundle": "TestBundle", - "stagingBundle": None - } - - -@pytest.fixture -def sample_addon_info(): - yield { - "name": "slack", - "title": "Slack addon", - "versions": { - "1.0.0": { - "hasSettings": True, - "hasSiteSettings": False, - "clientPyproject": { - "tool": { - "poetry": { - "dependencies": { - "nxtools": "^1.6", - "orjson": "^3.6.7", - "typer": "^0.4.1", - "email-validator": "^1.1.3", - "python": "^3.10", - "fastapi": "^0.73.0" - } - } - } - }, - "clientSourceInfo": [ - { - "type": "http", - "path": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing", # noqa - "filename": "dummy.zip" - }, - { - "type": "filesystem", - "path": { - "windows": "P:/sources/some_file.zip", - "linux": "/mnt/srv/sources/some_file.zip", - "darwin": "/Volumes/srv/sources/some_file.zip" - } - } - ], - "frontendScopes": { - "project": { - "sidebar": "hierarchy", - } - }, - "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658" # noqa - } - }, - "description": "" - } - - -def test_register(printer): - download_factory = DownloadFactory() - - assert len(download_factory._downloaders) == 0, "Contains registered" - - download_factory.register_format(UrlType.FILESYSTEM, OSDownloader) - assert len(download_factory._downloaders) == 1, "Should contain one" - - -def test_get_downloader(printer, download_factory): - assert download_factory.get_downloader(UrlType.FILESYSTEM.value), "Should find" # noqa - - with pytest.raises(ValueError): - download_factory.get_downloader("unknown"), "Shouldn't find" - - -def test_addon_info(printer, sample_addon_info): - """Tests parsing of expected payload from v4 server into AadonInfo.""" - valid_minimum = { - "name": "slack", - "versions": { - "1.0.0": { - "clientSourceInfo": [ - { - "type": "filesystem", - "path": { - "windows": "P:/sources/some_file.zip", - "linux": "/mnt/srv/sources/some_file.zip", - "darwin": "/Volumes/srv/sources/some_file.zip" - } - } - ] - } - } - } - - assert AddonInfo.from_dict(valid_minimum), "Missing required fields" - - addon = AddonInfo.from_dict(sample_addon_info) - assert addon, "Should be created" - assert addon.name == "slack", "Incorrect name" - assert "1.0.0" in addon.versions, "Version is not in versions" - - with pytest.raises(TypeError): - assert addon["name"], "Dict approach not implemented" - - addon_as_dict = attr.asdict(addon) - assert addon_as_dict["name"], "Dict approach should work" - - -def _get_dist_item(dist_items, name, version): - final_dist_info = next( - ( - dist_info - for dist_info in dist_items - if ( - dist_info["addon_name"] == name - and dist_info["addon_version"] == version - ) - ), - {} - ) - return final_dist_info["dist_item"] - - -def test_update_addon_state( - printer, sample_addon_info, temp_folder, download_factory, sample_bundles -): - """Tests possible cases of addon update.""" - - addon_version = list(sample_addon_info["versions"])[0] - broken_addon_info = copy.deepcopy(sample_addon_info) - - # Cause crash because of invalid hash - broken_addon_info["versions"][addon_version]["hash"] = "brokenhash" - distribution = AyonDistribution( - addon_dirpath=temp_folder, - dependency_dirpath=temp_folder, - dist_factory=download_factory, - addons_info=[broken_addon_info], - dependency_packages_info=[], - bundles_info=sample_bundles - ) - distribution.distribute() - dist_items = distribution.get_addon_dist_items() - slack_dist_item = _get_dist_item( - dist_items, - sample_addon_info["name"], - addon_version - ) - slack_state = slack_dist_item.state - assert slack_state == UpdateState.UPDATE_FAILED, ( - "Update should have failed because of wrong hash") - - # Fix cache and validate if was updated - distribution = AyonDistribution( - addon_dirpath=temp_folder, - dependency_dirpath=temp_folder, - dist_factory=download_factory, - addons_info=[sample_addon_info], - dependency_packages_info=[], - bundles_info=sample_bundles - ) - distribution.distribute() - dist_items = distribution.get_addon_dist_items() - slack_dist_item = _get_dist_item( - dist_items, - sample_addon_info["name"], - addon_version - ) - assert slack_dist_item.state == UpdateState.UPDATED, ( - "Addon should have been updated") - - # Is UPDATED without calling distribute - distribution = AyonDistribution( - addon_dirpath=temp_folder, - dependency_dirpath=temp_folder, - dist_factory=download_factory, - addons_info=[sample_addon_info], - dependency_packages_info=[], - bundles_info=sample_bundles - ) - dist_items = distribution.get_addon_dist_items() - slack_dist_item = _get_dist_item( - dist_items, - sample_addon_info["name"], - addon_version - ) - assert slack_dist_item.state == UpdateState.UPDATED, ( - "Addon should already exist") diff --git a/common/ayon_common/distribution/ui/missing_bundle_window.py b/common/ayon_common/distribution/ui/missing_bundle_window.py deleted file mode 100644 index ae7a6a2976..0000000000 --- a/common/ayon_common/distribution/ui/missing_bundle_window.py +++ /dev/null @@ -1,146 +0,0 @@ -import sys - -from qtpy import QtWidgets, QtGui - -from ayon_common import is_staging_enabled -from ayon_common.resources import ( - get_icon_path, - load_stylesheet, -) -from ayon_common.ui_utils import get_qt_app - - -class MissingBundleWindow(QtWidgets.QDialog): - default_width = 410 - default_height = 170 - - def __init__( - self, url=None, bundle_name=None, use_staging=None, parent=None - ): - super().__init__(parent) - - icon_path = get_icon_path() - icon = QtGui.QIcon(icon_path) - self.setWindowIcon(icon) - self.setWindowTitle("Missing Bundle") - - self._url = url - self._bundle_name = bundle_name - self._use_staging = use_staging - self._first_show = True - - info_label = QtWidgets.QLabel("", self) - info_label.setWordWrap(True) - - btns_widget = QtWidgets.QWidget(self) - confirm_btn = QtWidgets.QPushButton("Exit", btns_widget) - - btns_layout = QtWidgets.QHBoxLayout(btns_widget) - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addStretch(1) - btns_layout.addWidget(confirm_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(info_label, 0) - main_layout.addStretch(1) - main_layout.addWidget(btns_widget, 0) - - confirm_btn.clicked.connect(self._on_confirm_click) - - self._info_label = info_label - self._confirm_btn = confirm_btn - - self._update_label() - - def set_url(self, url): - if url == self._url: - return - self._url = url - self._update_label() - - def set_bundle_name(self, bundle_name): - if bundle_name == self._bundle_name: - return - self._bundle_name = bundle_name - self._update_label() - - def set_use_staging(self, use_staging): - if self._use_staging == use_staging: - return - self._use_staging = use_staging - self._update_label() - - def showEvent(self, event): - super().showEvent(event) - if self._first_show: - self._first_show = False - self._on_first_show() - self._recalculate_sizes() - - def resizeEvent(self, event): - super().resizeEvent(event) - self._recalculate_sizes() - - def _recalculate_sizes(self): - hint = self._confirm_btn.sizeHint() - new_width = max((hint.width(), hint.height() * 3)) - self._confirm_btn.setMinimumWidth(new_width) - - def _on_first_show(self): - self.setStyleSheet(load_stylesheet()) - self.resize(self.default_width, self.default_height) - - def _on_confirm_click(self): - self.accept() - self.close() - - def _update_label(self): - self._info_label.setText(self._get_label()) - - def _get_label(self): - url_part = f" {self._url}" if self._url else "" - - if self._bundle_name: - return ( - f"Requested release bundle {self._bundle_name}" - f" is not available on server{url_part}." - "

Try to restart AYON desktop launcher. Please" - " contact your administrator if issue persist." - ) - mode = "staging" if self._use_staging else "production" - return ( - f"No release bundle is set as {mode} on the AYON" - f" server{url_part} so there is nothing to launch." - "

Please contact your administrator" - " to resolve the issue." - ) - - -def main(): - """Show message that server does not have set bundle to use. - - It is possible to pass url as argument to show it in the message. To use - this feature, pass `--url ` as argument to this script. - """ - - url = None - bundle_name = None - if "--url" in sys.argv: - url_index = sys.argv.index("--url") + 1 - if url_index < len(sys.argv): - url = sys.argv[url_index] - - if "--bundle" in sys.argv: - bundle_index = sys.argv.index("--bundle") + 1 - if bundle_index < len(sys.argv): - bundle_name = sys.argv[bundle_index] - - use_staging = is_staging_enabled() - app = get_qt_app() - window = MissingBundleWindow(url, bundle_name, use_staging) - window.show() - app.exec_() - - -if __name__ == "__main__": - main() diff --git a/common/ayon_common/distribution/utils.py b/common/ayon_common/distribution/utils.py deleted file mode 100644 index a8b755707a..0000000000 --- a/common/ayon_common/distribution/utils.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import subprocess - -from ayon_common.utils import get_ayon_appdirs, get_ayon_launch_args - - -def get_local_dir(*subdirs): - """Get product directory in user's home directory. - - Each user on machine have own local directory where are downloaded updates, - addons etc. - - Returns: - str: Path to product local directory. - """ - - if not subdirs: - raise ValueError("Must fill dir_name if nothing else provided!") - - local_dir = get_ayon_appdirs(*subdirs) - if not os.path.isdir(local_dir): - try: - os.makedirs(local_dir) - except Exception: # TODO fix exception - raise RuntimeError(f"Cannot create {local_dir}") - - return local_dir - - -def get_addons_dir(): - """Directory where addon packages are stored. - - Path to addons is defined using python module 'appdirs' which - - The path is stored into environment variable 'AYON_ADDONS_DIR'. - Value of environment variable can be overriden, but we highly recommended - to use that option only for development purposes. - - Returns: - str: Path to directory where addons should be downloaded. - """ - - addons_dir = os.environ.get("AYON_ADDONS_DIR") - if not addons_dir: - addons_dir = get_local_dir("addons") - os.environ["AYON_ADDONS_DIR"] = addons_dir - return addons_dir - - -def get_dependencies_dir(): - """Directory where dependency packages are stored. - - Path to addons is defined using python module 'appdirs' which - - The path is stored into environment variable 'AYON_DEPENDENCIES_DIR'. - Value of environment variable can be overriden, but we highly recommended - to use that option only for development purposes. - - Returns: - str: Path to directory where dependency packages should be downloaded. - """ - - dependencies_dir = os.environ.get("AYON_DEPENDENCIES_DIR") - if not dependencies_dir: - dependencies_dir = get_local_dir("dependency_packages") - os.environ["AYON_DEPENDENCIES_DIR"] = dependencies_dir - return dependencies_dir - - -def show_missing_bundle_information(url, bundle_name=None): - """Show missing bundle information window. - - This function should be called when server does not have set bundle for - production or staging, or when bundle that should be used is not available - on server. - - Using subprocess to show the dialog. Is blocking and is waiting until - dialog is closed. - - Args: - url (str): Server url where bundle is not set. - bundle_name (Optional[str]): Name of bundle that was not found. - """ - - ui_dir = os.path.join(os.path.dirname(__file__), "ui") - script_path = os.path.join(ui_dir, "missing_bundle_window.py") - args = get_ayon_launch_args(script_path, "--skip-bootstrap", "--url", url) - if bundle_name: - args.extend(["--bundle", bundle_name]) - subprocess.call(args) diff --git a/common/ayon_common/resources/AYON.icns b/common/ayon_common/resources/AYON.icns deleted file mode 100644 index 2ec66cf3e0bb522c40209b5f1767ffd01014b0a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40634 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

H};dB$t%vn!rC`t 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 Vr*$JL34HDeq4bR}Rp~lD`afSKqZR-F diff --git a/common/ayon_common/resources/__init__.py b/common/ayon_common/resources/__init__.py deleted file mode 100644 index 2b516feff3..0000000000 --- a/common/ayon_common/resources/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -import os - -from ayon_common.utils import is_staging_enabled - -RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def get_resource_path(*args): - path_items = list(args) - path_items.insert(0, RESOURCES_DIR) - return os.path.sep.join(path_items) - - -def get_icon_path(): - if is_staging_enabled(): - return get_resource_path("AYON_staging.png") - return get_resource_path("AYON.png") - - -def load_stylesheet(): - stylesheet_path = get_resource_path("stylesheet.css") - - with open(stylesheet_path, "r") as stream: - content = stream.read() - return content diff --git a/common/ayon_common/resources/edit.png b/common/ayon_common/resources/edit.png deleted file mode 100644 index a5a07998a65fa0c37e2089b4da30db757e42115e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9138 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 diff --git a/common/ayon_common/resources/eye.png b/common/ayon_common/resources/eye.png deleted file mode 100644 index 5a683e29748b35b3b64a200cb9723e82df45f4b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2152 zcmai0dstIt93Gi@K}1AK5$ilhQ4HAG#Z7kHAlarv9U_+)52cRn3=U&EhwZ>N#7GK* zAtv}_f~X1RrMQNGK3E`_G;gFyW#kPtEFnU861>%SHUdqpKh8P(zW4pT=l6Sm-}jy6 zgoQ43ndmu@!C<(A$Ry$5Ii0@7zXa}AO`<^t_f`G3=Ox+lsoQqojl#IUJPsC?Dy)e-<~A9e{EIA z2qq1LIz@%4?PUQu2WliVlu2p87RQ4Ii{Ql?4G!$IKw#_O@p{Yvv6%v`A@WrELObEHO$y>1b71p>Qv?|~M!;a?Aj0(E^f7>A#^Nq7WiXsF zanP8j8p2@s=#T9iz0>8ZP<}T^%toqu=t8RV^cK;{W+B5aNrsb!i zj#T(0_uXz<(-bA}mF~T3cAV1g z`Q!@km7S*cAZ64)V9FAjd+^XsnOp`qd~uScqb=LTmL7JgneuAyqOcKHsI zId_6EJKHtG1L8K2p7W|o#06JKkN4dAK2^PY`sYoHems9H*`u;L2;Qe1qt=XRWoK4c z`=&9kMh#ub+j{HsTW6HhTip6)ZL8lR4v5`)as0-YCoL1&PUoDzxHdO^#@V&rp%2R- zuB+yu0Kf=!mq?PT=Hn*Dh_bC}42 zBhD+UzbsjhRc@WzTWj`@CfA(1(RHi*$1^4K;J~`G$2^Z7-)Oxf_?o*@>!JCD(-;^r z`2OYW?Rz-3!XuvbRSo+JR^__%tSZa@t-tu#u55D+bRgEl*?s+Lc0h);Az-T8?$|6{ zJ+Arl8p=;4AFQq(TOmE3{!#a#ika5AuQkmri;m>QzT?^#0DqM#>kQg9C5Ul7FTy@qaAA}HTsH7rzZRX#RRxAbp diff --git a/common/ayon_common/resources/stylesheet.css b/common/ayon_common/resources/stylesheet.css deleted file mode 100644 index 01e664e9e8..0000000000 --- a/common/ayon_common/resources/stylesheet.css +++ /dev/null @@ -1,84 +0,0 @@ -* { - font-size: 10pt; - font-family: "Noto Sans"; - font-weight: 450; - outline: none; -} - -QWidget { - color: #D3D8DE; - background: #2C313A; - border-radius: 0px; -} - -QWidget:disabled { - color: #5b6779; -} - -QLabel { - background: transparent; -} - -QPushButton { - text-align:center center; - border: 0px solid transparent; - border-radius: 0.2em; - padding: 3px 5px 3px 5px; - background: #434a56; -} - -QPushButton:hover { - background: rgba(168, 175, 189, 0.3); - color: #F0F2F5; -} - -QPushButton:pressed {} - -QPushButton:disabled { - background: #434a56; -} - -QLineEdit { - border: 1px solid #373D48; - border-radius: 0.3em; - background: #21252B; - padding: 0.1em; -} - -QLineEdit:disabled { - background: #2C313A; -} -QLineEdit:hover { - border-color: rgba(168, 175, 189, .3); -} -QLineEdit:focus { - border-color: rgb(92, 173, 214); -} - -QLineEdit[state="invalid"] { - border-color: #AA5050; -} - -#Separator { - background: rgba(75, 83, 98, 127); -} - -#PasswordBtn { - border: none; - padding: 0.1em; - background: transparent; -} - -#PasswordBtn:hover { - background: #434a56; -} - -#LikeDisabledInput { - background: #2C313A; -} -#LikeDisabledInput:hover { - border-color: #373D48; -} -#LikeDisabledInput:focus { - border-color: #373D48; -} diff --git a/common/ayon_common/ui_utils.py b/common/ayon_common/ui_utils.py deleted file mode 100644 index a3894d0d9c..0000000000 --- a/common/ayon_common/ui_utils.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -from qtpy import QtWidgets, QtCore - - -def set_style_property(widget, property_name, property_value): - """Set widget's property that may affect style. - - Style of widget is polished if current property value is different. - """ - - cur_value = widget.property(property_name) - if cur_value == property_value: - return - widget.setProperty(property_name, property_value) - widget.style().polish(widget) - - -def get_qt_app(): - app = QtWidgets.QApplication.instance() - if app is not None: - return app - - for attr_name in ( - "AA_EnableHighDpiScaling", - "AA_UseHighDpiPixmaps", - ): - attr = getattr(QtCore.Qt, attr_name, None) - if attr is not None: - QtWidgets.QApplication.setAttribute(attr) - - if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"): - QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - - return QtWidgets.QApplication(sys.argv) diff --git a/common/ayon_common/utils.py b/common/ayon_common/utils.py deleted file mode 100644 index c0d0c7c0b1..0000000000 --- a/common/ayon_common/utils.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import sys -import appdirs - -IS_BUILT_APPLICATION = getattr(sys, "frozen", False) - - -def get_ayon_appdirs(*args): - """Local app data directory of AYON client. - - Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. - - Returns: - str: Path to directory/file in local app data dir. - """ - - return os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - *args - ) - - -def is_staging_enabled(): - """Check if staging is enabled. - - Returns: - bool: True if staging is enabled. - """ - - return os.getenv("AYON_USE_STAGING") == "1" - - -def _create_local_site_id(): - """Create a local site identifier. - - Returns: - str: Randomly generated site id. - """ - - from coolname import generate_slug - - new_id = generate_slug(3) - - print("Created local site id \"{}\"".format(new_id)) - - return new_id - - -def get_local_site_id(): - """Get local site identifier. - - Site id is created if does not exist yet. - - Returns: - str: Site id. - """ - - # used for background syncing - site_id = os.environ.get("AYON_SITE_ID") - if site_id: - return site_id - - site_id_path = get_ayon_appdirs("site_id") - if os.path.exists(site_id_path): - with open(site_id_path, "r") as stream: - site_id = stream.read() - - if not site_id: - site_id = _create_local_site_id() - with open(site_id_path, "w") as stream: - stream.write(site_id) - return site_id - - -def get_ayon_launch_args(*args): - """Launch arguments that can be used to launch ayon process. - - Args: - *args (str): Additional arguments. - - Returns: - list[str]: Launch arguments. - """ - - output = [sys.executable] - if not IS_BUILT_APPLICATION: - output.append(sys.argv[0]) - output.extend(args) - return output diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 4b4e0f3359..0540d7692d 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -78,6 +78,8 @@ from ._api import ( download_dependency_package, upload_dependency_package, + upload_addon_zip, + get_bundles, create_bundle, update_bundle, @@ -262,6 +264,8 @@ __all__ = ( "download_dependency_package", "upload_dependency_package", + "upload_addon_zip", + "get_bundles", "create_bundle", "update_bundle", diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 82ffdc7527..26a4b1530a 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -25,12 +25,29 @@ class GlobalServerAPI(ServerAPI): but that can be filled afterwards with calling 'login' method. """ - def __init__(self, site_id=None, client_version=None): + def __init__( + self, + site_id=None, + client_version=None, + default_settings_variant=None, + ssl_verify=None, + cert=None, + ): url = self.get_url() token = self.get_token() - super(GlobalServerAPI, self).__init__(url, token, site_id, client_version) - + super(GlobalServerAPI, self).__init__( + url, + token, + site_id, + client_version, + default_settings_variant, + ssl_verify, + cert, + # We want to make sure that server and api key validation + # happens all the time in 'GlobalServerAPI'. + create_session=False, + ) self.validate_server_availability() self.create_session() @@ -129,17 +146,6 @@ class ServiceContext: addon_version = None service_name = None - @staticmethod - def get_value_from_envs(env_keys, value=None): - if value: - return value - - for env_key in env_keys: - value = os.environ.get(env_key) - if value: - break - return value - @classmethod def init_service( cls, @@ -150,14 +156,8 @@ class ServiceContext: service_name=None, connect=True ): - token = cls.get_value_from_envs( - ("AY_API_KEY", "AYON_API_KEY"), - token - ) - server_url = cls.get_value_from_envs( - ("AY_SERVER_URL", "AYON_SERVER_URL"), - server_url - ) + token = token or os.environ.get("AYON_API_KEY") + server_url = server_url or os.environ.get("AYON_SERVER_URL") if not server_url: raise FailedServiceInit("URL to server is not set") @@ -166,18 +166,9 @@ class ServiceContext: "Token to server {} is not set".format(server_url) ) - addon_name = cls.get_value_from_envs( - ("AY_ADDON_NAME", "AYON_ADDON_NAME"), - addon_name - ) - addon_version = cls.get_value_from_envs( - ("AY_ADDON_VERSION", "AYON_ADDON_VERSION"), - addon_version - ) - service_name = cls.get_value_from_envs( - ("AY_SERVICE_NAME", "AYON_SERVICE_NAME"), - service_name - ) + addon_name = addon_name or os.environ.get("AYON_ADDON_NAME") + addon_version = addon_version or os.environ.get("AYON_ADDON_VERSION") + service_name = service_name or os.environ.get("AYON_SERVICE_NAME") cls.token = token cls.server_url = server_url @@ -618,6 +609,11 @@ def delete_dependency_package(*args, **kwargs): return con.delete_dependency_package(*args, **kwargs) +def upload_addon_zip(*args, **kwargs): + con = get_server_api_connection() + return con.upload_addon_zip(*args, **kwargs) + + def get_project_anatomy_presets(*args, **kwargs): con = get_server_api_connection() return con.get_project_anatomy_presets(*args, **kwargs) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index c886fed976..c578124cfc 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -4,7 +4,6 @@ import io import json import logging import collections -import datetime import platform import copy import uuid @@ -325,6 +324,8 @@ class ServerAPI(object): available then 'True' is used. cert (Optional[str]): Path to certificate file. Looks for env variable value 'AYON_CERT_FILE' by default. + create_session (Optional[bool]): Create session for connection if + token is available. Default is True. """ def __init__( @@ -336,6 +337,7 @@ class ServerAPI(object): default_settings_variant=None, ssl_verify=None, cert=None, + create_session=True, ): if not base_url: raise ValueError("Invalid server URL {}".format(str(base_url))) @@ -367,6 +369,7 @@ class ServerAPI(object): self._access_token_is_service = None self._token_is_valid = None + self._token_validation_started = False self._server_available = None self._server_version = None self._server_version_tuple = None @@ -389,6 +392,11 @@ class ServerAPI(object): self._as_user_stack = _AsUserStack() self._thumbnail_cache = ThumbnailCache(True) + # Create session + if self._access_token and create_session: + self.validate_server_availability() + self.create_session() + @property def log(self): if self._log is None: @@ -652,6 +660,7 @@ class ServerAPI(object): def validate_token(self): try: + self._token_validation_started = True # TODO add other possible validations # - existence of 'user' key in info # - validate that 'site_id' is in 'sites' in info @@ -661,6 +670,9 @@ class ServerAPI(object): except UnauthorizedError: self._token_is_valid = False + + finally: + self._token_validation_started = False return self._token_is_valid def set_token(self, token): @@ -673,8 +685,25 @@ class ServerAPI(object): self._token_is_valid = None self.close_session() - def create_session(self): + def create_session(self, ignore_existing=True, force=False): + """Create a connection session. + + Session helps to keep connection with server without + need to reconnect on each call. + + Args: + ignore_existing (bool): If session already exists, + ignore creation. + force (bool): If session already exists, close it and + create new. + """ + + if force and self._session is not None: + self.close_session() + if self._session is not None: + if ignore_existing: + return raise ValueError("Session is already created.") self._as_user_stack.clear() @@ -841,7 +870,19 @@ class ServerAPI(object): self._access_token) return headers - def login(self, username, password): + def login(self, username, password, create_session=True): + """Login to server. + + Args: + username (str): Username. + password (str): Password. + create_session (Optional[bool]): Create session after login. + Default: True. + + Raises: + AuthenticationError: Login failed. + """ + if self.has_valid_token: try: user_info = self.get_user() @@ -851,7 +892,8 @@ class ServerAPI(object): current_username = user_info.get("name") if current_username == username: self.close_session() - self.create_session() + if create_session: + self.create_session() return self.reset_token() @@ -875,7 +917,9 @@ class ServerAPI(object): if not self.has_valid_token: raise AuthenticationError("Invalid credentials") - self.create_session() + + if create_session: + self.create_session() def logout(self, soft=False): if self._access_token: @@ -888,6 +932,15 @@ class ServerAPI(object): def _do_rest_request(self, function, url, **kwargs): if self._session is None: + # Validate token if was not yet validated + # - ignore validation if we're in middle of + # validation + if ( + self._token_is_valid is None + and not self._token_validation_started + ): + self.validate_token() + if "headers" not in kwargs: kwargs["headers"] = self.get_headers() @@ -1328,6 +1381,7 @@ class ServerAPI(object): response = post_func(url, data=stream, **kwargs) response.raise_for_status() progress.set_transferred_size(size) + return response def upload_file( self, endpoint, filepath, progress=None, request_type=None @@ -1344,6 +1398,9 @@ class ServerAPI(object): to track upload progress. request_type (Optional[RequestType]): Type of request that will be used to upload file. + + Returns: + requests.Response: Response object. """ if endpoint.startswith(self._base_url): @@ -1362,7 +1419,7 @@ class ServerAPI(object): progress.set_started() try: - self._upload_file(url, filepath, progress, request_type) + return self._upload_file(url, filepath, progress, request_type) except Exception as exc: progress.set_failed(str(exc)) @@ -1640,7 +1697,7 @@ class ServerAPI(object): Args: addon_name (str): Name of addon. addon_version (str): Version of addon. - subpaths (tuple[str]): Any amount of subpaths that are added to + *subpaths (str): Any amount of subpaths that are added to addon url. Returns: @@ -1848,9 +1905,12 @@ class ServerAPI(object): dst_filename (str): Destination filename. progress (Optional[TransferProgress]): Object that gives ability to track download progress. + + Returns: + requests.Response: Response object. """ - self.upload_file( + return self.upload_file( "desktop/installers/{}".format(dst_filename), src_filepath, progress=progress @@ -2162,6 +2222,33 @@ class ServerAPI(object): return create_dependency_package_basename(platform_name) + def upload_addon_zip(self, src_filepath, progress=None): + """Upload addon zip file to server. + + File is validated on server. If it is valid, it is installed. It will + create an event job which can be tracked (tracking part is not + implemented yet). + + Example output: + {'eventId': 'a1bfbdee27c611eea7580242ac120003'} + + Args: + src_filepath (str): Path to a zip file. + progress (Optional[TransferProgress]): Object to keep track about + upload state. + + Returns: + dict[str, Any]: Response data from server. + """ + + response = self.upload_file( + "addons/install", + src_filepath, + progress=progress, + request_type=RequestTypes.post, + ) + return response.json() + def _get_bundles_route(self): major, minor, patch, _, _ = self.server_version_tuple # Backwards compatibility for AYON server 0.3.0 @@ -3051,6 +3138,65 @@ class ServerAPI(object): fill_own_attribs(project) return project + def get_folders_hierarchy( + self, + project_name, + search_string=None, + folder_types=None + ): + """Get project hierarchy. + + All folders in project in hierarchy data structure. + + Example output: + { + "hierarchy": [ + { + "id": "...", + "name": "...", + "label": "...", + "status": "...", + "folderType": "...", + "hasTasks": False, + "taskNames": [], + "parents": [], + "parentId": None, + "children": [...children folders...] + }, + ... + ] + } + + Args: + project_name (str): Project where to look for folders. + search_string (Optional[str]): Search string to filter folders. + folder_types (Optional[Iterable[str]]): Folder types to filter. + + Returns: + dict[str, Any]: Response data from server. + """ + + if folder_types: + folder_types = ",".join(folder_types) + + query_fields = [ + "{}={}".format(key, value) + for key, value in ( + ("search", search_string), + ("types", folder_types), + ) + if value + ] + query = "" + if query_fields: + query = "?{}".format(",".join(query_fields)) + + response = self.get( + "projects/{}/hierarchy{}".format(project_name, query) + ) + response.raise_for_status() + return response.data + def get_folders( self, project_name, @@ -3622,7 +3768,6 @@ class ServerAPI(object): if filtered_product is not None: yield filtered_product - def get_product_by_id( self, project_name, diff --git a/openpype/vendor/python/common/ayon_api/thumbnails.py b/openpype/vendor/python/common/ayon_api/thumbnails.py index 11734ca762..50acd94dcb 100644 --- a/openpype/vendor/python/common/ayon_api/thumbnails.py +++ b/openpype/vendor/python/common/ayon_api/thumbnails.py @@ -50,7 +50,7 @@ class ThumbnailCache: """ if self._thumbnails_dir is None: - directory = appdirs.user_data_dir("AYON", "Ynput") + directory = appdirs.user_data_dir("ayon", "ynput") self._thumbnails_dir = os.path.join(directory, "thumbnails") return self._thumbnails_dir diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py index 69fd8e9b41..93822a58ac 100644 --- a/openpype/vendor/python/common/ayon_api/utils.py +++ b/openpype/vendor/python/common/ayon_api/utils.py @@ -359,7 +359,7 @@ class TransferProgress: def __init__(self): self._started = False self._transfer_done = False - self._transfered = 0 + self._transferred = 0 self._content_size = None self._failed = False @@ -369,25 +369,66 @@ class TransferProgress: self._destination_url = "N/A" def get_content_size(self): + """Content size in bytes. + + Returns: + Union[int, None]: Content size in bytes or None + if is unknown. + """ + return self._content_size def set_content_size(self, content_size): + """Set content size in bytes. + + Args: + content_size (int): Content size in bytes. + + Raises: + ValueError: If content size was already set. + """ + if self._content_size is not None: raise ValueError("Content size was set more then once") self._content_size = content_size def get_started(self): + """Transfer was started. + + Returns: + bool: True if transfer started. + """ + return self._started def set_started(self): + """Mark that transfer started. + + Raises: + ValueError: If transfer was already started. + """ + if self._started: raise ValueError("Progress already started") self._started = True def get_transfer_done(self): + """Transfer finished. + + Returns: + bool: Transfer finished. + """ + return self._transfer_done def set_transfer_done(self): + """Mark progress as transfer finished. + + Raises: + ValueError: If progress was already marked as done + or wasn't started yet. + """ + if self._transfer_done: raise ValueError("Progress was already marked as done") if not self._started: @@ -395,41 +436,117 @@ class TransferProgress: self._transfer_done = True def get_failed(self): + """Transfer failed. + + Returns: + bool: True if transfer failed. + """ + return self._failed def get_fail_reason(self): + """Get reason why transfer failed. + + Returns: + Union[str, None]: Reason why transfer + failed or None. + """ + return self._fail_reason def set_failed(self, reason): + """Mark progress as failed. + + Args: + reason (str): Reason why transfer failed. + """ + self._fail_reason = reason self._failed = True def get_transferred_size(self): - return self._transfered + """Already transferred size in bytes. - def set_transferred_size(self, transfered): - self._transfered = transfered + Returns: + int: Already transferred size in bytes. + """ + + return self._transferred + + def set_transferred_size(self, transferred): + """Set already transferred size in bytes. + + Args: + transferred (int): Already transferred size in bytes. + """ + + self._transferred = transferred def add_transferred_chunk(self, chunk_size): - self._transfered += chunk_size + """Add transferred chunk size in bytes. + + Args: + chunk_size (int): Add transferred chunk size + in bytes. + """ + + self._transferred += chunk_size def get_source_url(self): + """Source url from where transfer happens. + + Note: + Consider this as title. Must be set using + 'set_source_url' or 'N/A' will be returned. + + Returns: + str: Source url from where transfer happens. + """ + return self._source_url def set_source_url(self, url): + """Set source url from where transfer happens. + + Args: + url (str): Source url from where transfer happens. + """ + self._source_url = url def get_destination_url(self): + """Destination url where transfer happens. + + Note: + Consider this as title. Must be set using + 'set_source_url' or 'N/A' will be returned. + + Returns: + str: Destination url where transfer happens. + """ + return self._destination_url def set_destination_url(self, url): + """Set destination url where transfer happens. + + Args: + url (str): Destination url where transfer happens. + """ + self._destination_url = url @property def is_running(self): + """Check if transfer is running. + + Returns: + bool: True if transfer is running. + """ + if ( not self.started - or self.done + or self.transfer_done or self.failed ): return False @@ -437,9 +554,16 @@ class TransferProgress: @property def transfer_progress(self): + """Get transfer progress in percents. + + Returns: + Union[float, None]: Transfer progress in percents or 'None' + if content size is unknown. + """ + if self._content_size is None: return None - return (self._transfered * 100.0) / float(self._content_size) + return (self._transferred * 100.0) / float(self._content_size) content_size = property(get_content_size, set_content_size) started = property(get_started) @@ -448,7 +572,6 @@ class TransferProgress: fail_reason = property(get_fail_reason) source_url = property(get_source_url, set_source_url) 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) diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index 238f6e9426..93024ea5f2 100644 --- a/openpype/vendor/python/common/ayon_api/version.py +++ b/openpype/vendor/python/common/ayon_api/version.py @@ -1,2 +1,2 @@ """Package declaring Python API for Ayon server.""" -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/setup.py b/setup.py index 260728dde6..4b6f286730 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Setup info for building OpenPype 3.0.""" import os -import sys import re import platform import distutils.spawn @@ -125,7 +124,6 @@ bin_includes = [ include_files = [ "igniter", "openpype", - "common", "schema", "LICENSE", "README.md" @@ -170,22 +168,7 @@ executables = [ target_name="openpype_console", icon=icon_path.as_posix() ), - Executable( - "ayon_start.py", - base=base, - target_name="ayon", - icon=icon_path.as_posix() - ), ] -if IS_WINDOWS: - executables.append( - Executable( - "ayon_start.py", - base=None, - target_name="ayon_console", - icon=icon_path.as_posix() - ) - ) if IS_LINUX: executables.append( diff --git a/tools/run_tray_ayon.ps1 b/tools/run_tray_ayon.ps1 deleted file mode 100644 index 54a80f93fd..0000000000 --- a/tools/run_tray_ayon.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -<# -.SYNOPSIS - Helper script AYON Tray. - -.DESCRIPTION - - -.EXAMPLE - -PS> .\run_tray.ps1 - -#> -$current_dir = Get-Location -$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent -$ayon_root = (Get-Item $script_dir).parent.FullName - -# Install PSWriteColor to support colorized output to terminal -$env:PSModulePath = $env:PSModulePath + ";$($ayon_root)\tools\modules\powershell" - -$env:_INSIDE_OPENPYPE_TOOL = "1" - -# make sure Poetry is in PATH -if (-not (Test-Path 'env:POETRY_HOME')) { - $env:POETRY_HOME = "$ayon_root\.poetry" -} -$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" - - -Set-Location -Path $ayon_root - -Write-Color -Text ">>> ", "Reading Poetry ... " -Color Green, Gray -NoNewline -if (-not (Test-Path -PathType Container -Path "$($env:POETRY_HOME)\bin")) { - Write-Color -Text "NOT FOUND" -Color Yellow - Write-Color -Text "*** ", "We need to install Poetry create virtual env first ..." -Color Yellow, Gray - & "$ayon_root\tools\create_env.ps1" -} else { - Write-Color -Text "OK" -Color Green -} - -& "$($env:POETRY_HOME)\bin\poetry" run python "$($ayon_root)\ayon_start.py" tray --debug -Set-Location -Path $current_dir diff --git a/tools/run_tray_ayon.sh b/tools/run_tray_ayon.sh deleted file mode 100755 index 3039750b87..0000000000 --- a/tools/run_tray_ayon.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Run AYON Tray - -# Colors for terminal - -RST='\033[0m' # Text Reset - -# Regular Colors -Black='\033[0;30m' # Black -Red='\033[0;31m' # Red -Green='\033[0;32m' # Green -Yellow='\033[0;33m' # Yellow -Blue='\033[0;34m' # Blue -Purple='\033[0;35m' # Purple -Cyan='\033[0;36m' # Cyan -White='\033[0;37m' # White - -# Bold -BBlack='\033[1;30m' # Black -BRed='\033[1;31m' # Red -BGreen='\033[1;32m' # Green -BYellow='\033[1;33m' # Yellow -BBlue='\033[1;34m' # Blue -BPurple='\033[1;35m' # Purple -BCyan='\033[1;36m' # Cyan -BWhite='\033[1;37m' # White - -# Bold High Intensity -BIBlack='\033[1;90m' # Black -BIRed='\033[1;91m' # Red -BIGreen='\033[1;92m' # Green -BIYellow='\033[1;93m' # Yellow -BIBlue='\033[1;94m' # Blue -BIPurple='\033[1;95m' # Purple -BICyan='\033[1;96m' # Cyan -BIWhite='\033[1;97m' # White - - -############################################################################## -# Return absolute path -# Globals: -# None -# Arguments: -# Path to resolve -# Returns: -# None -############################################################################### -realpath () { - echo $(cd $(dirname "$1"); pwd)/$(basename "$1") -} - -# Main -main () { - # Directories - ayon_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) - - _inside_openpype_tool="1" - - if [[ -z $POETRY_HOME ]]; then - export POETRY_HOME="$ayon_root/.poetry" - fi - - echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" - if [ -f "$POETRY_HOME/bin/poetry" ]; then - echo -e "${BIGreen}OK${RST}" - else - echo -e "${BIYellow}NOT FOUND${RST}" - echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." - . "$ayon_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } - fi - - pushd "$ayon_root" > /dev/null || return > /dev/null - - echo -e "${BIGreen}>>>${RST} Running AYON Tray with debug option ..." - "$POETRY_HOME/bin/poetry" run python3 "$ayon_root/ayon_start.py" tray --debug -} - -main