From 899f9965e4a121a097b671264577d088d652a4a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 14 Apr 2023 16:30:50 +0200 Subject: [PATCH] AYON: Small fixes (#4841) * us constants from ayon api for environments * updated ayon api * define 'BUILTIN_OCIO_ROOT' in ayon start script * fox missing modules settings * use window open on QApplication exec * fix graphql queries * implemented 'get_archived_assets' --- ayon_start.py | 22 +- common/ayon_common/connection/credentials.py | 19 +- .../ayon_common/connection/ui/login_window.py | 54 +- openpype/client/server/entities.py | 17 +- openpype/client/server/openpype_comp.py | 4 +- openpype/client/server/operations.py | 4 +- openpype/settings/ayon_settings.py | 18 +- .../vendor/python/common/ayon_api/__init__.py | 54 +- .../vendor/python/common/ayon_api/_api.py | 276 ++++- .../python/common/ayon_api/constants.py | 7 +- .../python/common/ayon_api/entity_hub.py | 156 ++- .../vendor/python/common/ayon_api/events.py | 2 +- .../python/common/ayon_api/exceptions.py | 12 +- .../vendor/python/common/ayon_api/graphql.py | 151 ++- .../python/common/ayon_api/graphql_queries.py | 76 +- .../python/common/ayon_api/operations.py | 5 +- .../python/common/ayon_api/server_api.py | 1011 ++++++++++++++--- .../vendor/python/common/ayon_api/version.py | 2 +- 18 files changed, 1550 insertions(+), 340 deletions(-) diff --git a/ayon_start.py b/ayon_start.py index 11677b4415..e45fbf4680 100644 --- a/ayon_start.py +++ b/ayon_start.py @@ -74,7 +74,6 @@ if "--headless" in sys.argv: elif os.getenv("OPENPYPE_HEADLESS_MODE") != "1": os.environ.pop("OPENPYPE_HEADLESS_MODE", None) - IS_BUILT_APPLICATION = getattr(sys, "frozen", False) HEADLESS_MODE_ENABLED = os.environ.get("OPENPYPE_HEADLESS_MODE") == "1" SILENT_MODE_ENABLED = any(arg in _silent_commands for arg in sys.argv) @@ -137,6 +136,14 @@ os.environ["OPENPYPE_REPOS_ROOT"] = AYON_ROOT 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 @@ -183,6 +190,7 @@ if not os.getenv("SSL_CERT_FILE"): elif os.getenv("SSL_CERT_FILE") != certifi.where(): _print("--- your system is set to use custom CA certificate bundle.") +from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY from ayon_common.connection.credentials import ( ask_to_login_ui, add_server, @@ -252,12 +260,12 @@ def _connect_to_ayon_server(): if HEADLESS_MODE_ENABLED: _print("!!! Cannot open v4 Login dialog in headless mode.") _print(( - "!!! Please use `AYON_SERVER_URL` to specify server address" - " and 'AYON_TOKEN' to specify user's token." - )) + "!!! 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("AYON_SERVER_URL") + 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) @@ -345,10 +353,10 @@ def boot(): t.echo(i) try: - cli.main(obj={}, prog_name="openpype") + cli.main(obj={}, prog_name="ayon") except Exception: # noqa exc_info = sys.exc_info() - _print("!!! OpenPype crashed:") + _print("!!! AYON crashed:") traceback.print_exception(*exc_info) sys.exit(1) diff --git a/common/ayon_common/connection/credentials.py b/common/ayon_common/connection/credentials.py index 4d1a97ee00..23cac9a8fc 100644 --- a/common/ayon_common/connection/credentials.py +++ b/common/ayon_common/connection/credentials.py @@ -17,6 +17,7 @@ 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, @@ -383,7 +384,7 @@ def load_environments(): """Load environments on startup. Handle environments needed for connection with server. Environments are - 'AYON_SERVER_URL' and 'AYON_TOKEN'. + '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 @@ -394,16 +395,16 @@ def load_environments(): based on server url. """ - server_url = os.environ.get("AYON_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["AYON_SERVER_URL"] = server_url + os.environ[SERVER_URL_ENV_KEY] = server_url - if not os.environ.get("AYON_TOKEN"): + if not os.environ.get(SERVER_API_ENV_KEY): if token := load_token(server_url): - os.environ["AYON_TOKEN"] = token + os.environ[SERVER_API_ENV_KEY] = token def set_environments(url: str, token: str): @@ -441,7 +442,7 @@ def need_server_or_login() -> bool: bool: 'True' if server and token are available. Otherwise 'False'. """ - server_url = os.environ.get("AYON_SERVER_URL") + server_url = os.environ.get(SERVER_URL_ENV_KEY) if not server_url: return True @@ -450,12 +451,14 @@ def need_server_or_login() -> bool: except UrlError: return True - token = os.environ.get("AYON_TOKEN") + token = os.environ.get(SERVER_API_ENV_KEY) if token: return not is_token_valid(server_url, token) token = load_token(server_url) - return not is_token_valid(server_url, token) + if token: + return not is_token_valid(server_url, token) + return True def confirm_server_login(url, token, username): diff --git a/common/ayon_common/connection/ui/login_window.py b/common/ayon_common/connection/ui/login_window.py index d7c0558eec..566dc4f71f 100644 --- a/common/ayon_common/connection/ui/login_window.py +++ b/common/ayon_common/connection/ui/login_window.py @@ -674,29 +674,14 @@ def ask_to_login(url=None, username=None, always_on_top=False): if username: window.set_username(username) - _output = {"out": None} - - def _exec_window(): - window.exec_() - result = window.result() - out_url, out_token, out_username, _logged_out = result - _output["out"] = out_url, out_token, out_username - return _output["out"] - - # Use QTimer to exec dialog if application is not running yet - # - it is not possible to call 'exec_' on dialog without running app - # - it is but the window is stuck if not app_instance.startingUp(): - return _exec_window() - - timer = QtCore.QTimer() - timer.setSingleShot(True) - timer.timeout.connect(_exec_window) - timer.start() - # This can become main Qt loop. Maybe should live elsewhere - app_instance.exec_() - - return _output["out"] + 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): @@ -735,23 +720,10 @@ def change_user(url, username, api_key, always_on_top=False): ) window.set_logged_in(True, url, username, api_key) - _output = {"out": None} - - def _exec_window(): - window.exec_() - _output["out"] = window.result() - return _output["out"] - - # Use QTimer to exec dialog if application is not running yet - # - it is not possible to call 'exec_' on dialog without running app - # - it is but the window is stuck if not app_instance.startingUp(): - return _exec_window() - - timer = QtCore.QTimer() - timer.setSingleShot(True) - timer.timeout.connect(_exec_window) - timer.start() - # This can become main Qt loop. Maybe should live elsewhere - app_instance.exec_() - return _output["out"] + window.exec_() + else: + window.open() + # This can become main Qt loop. Maybe should live elsewhere + app_instance.exec_() + return window.result() diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 5dc8af9a6d..b49c8dd505 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -216,8 +216,21 @@ def get_assets( yield convert_v4_folder_to_v3(folder, project_name) -def get_archived_assets(*args, **kwargs): - raise NotImplementedError("'get_archived_assets' not implemented") +def get_archived_assets( + project_name, + asset_ids=None, + asset_names=None, + parent_ids=None, + fields=None +): + return get_assets( + project_name, + asset_ids, + asset_names, + parent_ids, + True, + fields + ) def get_asset_ids_with_subsets(project_name, asset_ids=None): diff --git a/openpype/client/server/openpype_comp.py b/openpype/client/server/openpype_comp.py index 00ee0aae92..df3ffcc0d3 100644 --- a/openpype/client/server/openpype_comp.py +++ b/openpype/client/server/openpype_comp.py @@ -16,7 +16,7 @@ def folders_tasks_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - folders_field = project_field.add_field("folders", has_edges=True) + folders_field = project_field.add_field_with_edges("folders") folders_field.set_filter("ids", folder_ids_var) folders_field.set_filter("parentIds", parent_folder_ids_var) folders_field.set_filter("names", folder_names_var) @@ -25,7 +25,7 @@ def folders_tasks_graphql_query(fields): fields = set(fields) fields.discard("tasks") - tasks_field = folders_field.add_field("tasks", has_edges=True) + tasks_field = folders_field.add_field_with_edges("tasks") tasks_field.add_field("name") tasks_field.add_field("taskType") diff --git a/openpype/client/server/operations.py b/openpype/client/server/operations.py index 6148f6a098..0456680737 100644 --- a/openpype/client/server/operations.py +++ b/openpype/client/server/operations.py @@ -857,7 +857,7 @@ def delete_project(project_name, con=None): return con.delete_project(project_name) -def create_thumbnail(project_name, src_filepath, con=None): +def create_thumbnail(project_name, src_filepath, thumbnail_id=None, con=None): if con is None: con = get_server_api_connection() - return con.create_thumbnail(project_name, src_filepath) + return con.create_thumbnail(project_name, src_filepath, thumbnail_id) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index df6379c3e5..30efd61fe4 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -253,19 +253,6 @@ def _convert_royalrender_system_settings(ayon_settings, output): def _convert_modules_system( ayon_settings, output, addon_versions, default_settings ): - # TODO remove when not needed - # - these modules are not and won't be in AYON avaialble - for module_name in ( - "addon_paths", - "avalon", - "job_queue", - "log_viewer", - "project_manager", - ): - output["modules"][module_name] = ( - default_settings["modules"][module_name] - ) - # TODO add all modules # TODO add 'enabled' values for key, func in ( @@ -282,6 +269,11 @@ def _convert_modules_system( func(ayon_settings, output) output_modules = output["modules"] + # TODO remove when not needed + for module_name, value in default_settings["modules"].items(): + if module_name not in output_modules: + output_modules[module_name] = value + for module_name, value in default_settings["modules"].items(): if "enabled" not in value or module_name not in output_modules: continue diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 700c1b3687..ee6672dd38 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -56,6 +56,7 @@ from ._api import ( query_graphql, get_addons_info, + get_addon_url, download_addon_private_file, get_dependencies_info, @@ -121,11 +122,35 @@ from ._api import ( get_representation_parents, get_repre_ids_by_context_filters, - create_thumbnail, get_thumbnail, get_folder_thumbnail, get_version_thumbnail, get_workfile_thumbnail, + create_thumbnail, + update_thumbnail, + + get_full_link_type_name, + get_link_types, + get_link_type, + create_link_type, + delete_link_type, + make_sure_link_type_exists, + + create_link, + delete_link, + get_entities_links, + get_folder_links, + get_folders_links, + get_task_links, + get_tasks_links, + get_subset_links, + get_subsets_links, + get_version_links, + get_versions_links, + get_representations_links, + get_representation_links, + + send_batch_operations, ) @@ -184,6 +209,7 @@ __all__ = ( "query_graphql", "get_addons_info", + "get_addon_url", "download_addon_private_file", "get_dependencies_info", @@ -248,9 +274,33 @@ __all__ = ( "get_representation_parents", "get_repre_ids_by_context_filters", - "create_thumbnail", "get_thumbnail", "get_folder_thumbnail", "get_version_thumbnail", "get_workfile_thumbnail", + "create_thumbnail", + "update_thumbnail", + + "get_full_link_type_name", + "get_link_types", + "get_link_type", + "create_link_type", + "delete_link_type", + "make_sure_link_type_exists", + + "create_link", + "delete_link", + "get_entities_links", + "get_folder_links", + "get_folders_links", + "get_task_links", + "get_tasks_links", + "get_subset_links", + "get_subsets_links", + "get_version_links", + "get_versions_links", + "get_representations_links", + "get_representation_links", + + "send_batch_operations", ) diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 6410b459eb..ed730841ae 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -11,7 +11,7 @@ import socket from .constants import ( SERVER_URL_ENV_KEY, - SERVER_TOKEN_ENV_KEY, + SERVER_API_ENV_KEY, ) from .server_api import ServerAPI from .exceptions import FailedServiceInit @@ -21,7 +21,7 @@ class GlobalServerAPI(ServerAPI): """Extended server api which also handles storing tokens and url. Created object expect to have set environment variables - 'AYON_SERVER_URL'. Also is expecting filled 'AYON_TOKEN' + 'AYON_SERVER_URL'. Also is expecting filled 'AYON_API_KEY' but that can be filled afterwards with calling 'login' method. """ @@ -44,7 +44,7 @@ class GlobalServerAPI(ServerAPI): previous_token = self._access_token super(GlobalServerAPI, self).login(username, password) if self.has_valid_token and previous_token != self._access_token: - os.environ[SERVER_TOKEN_ENV_KEY] = self._access_token + os.environ[SERVER_API_ENV_KEY] = self._access_token @staticmethod def get_url(): @@ -52,7 +52,7 @@ class GlobalServerAPI(ServerAPI): @staticmethod def get_token(): - return os.environ.get(SERVER_TOKEN_ENV_KEY) + return os.environ.get(SERVER_API_ENV_KEY) @staticmethod def set_environments(url, token): @@ -64,7 +64,7 @@ class GlobalServerAPI(ServerAPI): """ os.environ[SERVER_URL_ENV_KEY] = url or "" - os.environ[SERVER_TOKEN_ENV_KEY] = token or "" + os.environ[SERVER_API_ENV_KEY] = token or "" class GlobalContext: @@ -151,7 +151,7 @@ class ServiceContext: connect=True ): token = cls.get_value_from_envs( - ("AY_API_KEY", "AYON_TOKEN"), + ("AY_API_KEY", "AYON_API_KEY"), token ) server_url = cls.get_value_from_envs( @@ -322,7 +322,7 @@ def get_server_api_connection(): """Access to global scope object of GlobalServerAPI. This access expect to have set environment variables 'AYON_SERVER_URL' - and 'AYON_TOKEN'. + and 'AYON_API_KEY'. Returns: GlobalServerAPI: Object of connection to server. @@ -521,6 +521,11 @@ def get_addons_info(*args, **kwargs): return con.get_addons_info(*args, **kwargs) +def get_addon_url(addon_name, addon_version, *subpaths): + con = get_server_api_connection() + return con.get_addon_url(addon_name, addon_version, *subpaths) + + def download_addon_private_file(*args, **kwargs): con = get_server_api_connection() return con.download_addon_private_file(*args, **kwargs) @@ -776,11 +781,6 @@ def delete_project(project_name): return con.delete_project(project_name) -def create_thumbnail(project_name, src_filepath): - con = get_server_api_connection() - return con.create_thumbnail(project_name, src_filepath) - - def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None): con = get_server_api_connection() con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id) @@ -801,11 +801,259 @@ def get_workfile_thumbnail(project_name, workfile_id, thumbnail_id=None): return con.get_workfile_thumbnail(project_name, workfile_id, thumbnail_id) -def create_thumbnail(project_name, src_filepath): +def create_thumbnail(project_name, src_filepath, thumbnail_id=None): con = get_server_api_connection() - return con.create_thumbnail(project_name, src_filepath) + return con.create_thumbnail(project_name, src_filepath, thumbnail_id) + + +def update_thumbnail(project_name, thumbnail_id, src_filepath): + con = get_server_api_connection() + return con.update_thumbnail(project_name, thumbnail_id, src_filepath) def get_default_fields_for_type(entity_type): con = get_server_api_connection() return con.get_default_fields_for_type(entity_type) + + +def get_full_link_type_name(link_type_name, input_type, output_type): + con = get_server_api_connection() + return con.get_full_link_type_name( + link_type_name, input_type, output_type) + + +def get_link_types(project_name): + con = get_server_api_connection() + return con.get_link_types(project_name) + + +def get_link_type(project_name, link_type_name, input_type, output_type): + con = get_server_api_connection() + return con.get_link_type( + project_name, link_type_name, input_type, output_type) + + +def create_link_type( + project_name, link_type_name, input_type, output_type, data=None): + con = get_server_api_connection() + return con.create_link_type( + project_name, link_type_name, input_type, output_type, data=data) + + +def delete_link_type(project_name, link_type_name, input_type, output_type): + con = get_server_api_connection() + return con.delete_link_type( + project_name, link_type_name, input_type, output_type) + + +def make_sure_link_type_exists( + project_name, link_type_name, input_type, output_type, data=None +): + con = get_server_api_connection() + return con.make_sure_link_type_exists( + project_name, link_type_name, input_type, output_type, data=data + ) + + +def create_link( + project_name, + link_type_name, + input_id, + input_type, + output_id, + output_type +): + con = get_server_api_connection() + return con.create_link( + project_name, + link_type_name, + input_id, input_type, + output_id, output_type + ) + + +def delete_link(project_name, link_id): + con = get_server_api_connection() + return con.delete_link(project_name, link_id) + + +def get_entities_links( + project_name, + entity_type, + entity_ids=None, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_entities_links( + project_name, + entity_type, + entity_ids, + link_types, + link_direction + ) + + +def get_folders_links( + project_name, + folder_ids=None, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_folders_links( + project_name, + folder_ids, + link_types, + link_direction + ) + + +def get_folder_links( + project_name, + folder_id, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_folder_links( + project_name, + folder_id, + link_types, + link_direction + ) + + +def get_tasks_links( + project_name, + task_ids=None, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_tasks_links( + project_name, + task_ids, + link_types, + link_direction + ) + + +def get_task_links( + project_name, + task_id, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_task_links( + project_name, + task_id, + link_types, + link_direction + ) + + +def get_subsets_links( + project_name, + subset_ids=None, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_subsets_links( + project_name, + subset_ids, + link_types, + link_direction + ) + + +def get_subset_links( + project_name, + subset_id, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_subset_links( + project_name, + subset_id, + link_types, + link_direction + ) + + +def get_versions_links( + project_name, + version_ids=None, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_versions_links( + project_name, + version_ids, + link_types, + link_direction + ) + + +def get_version_links( + project_name, + version_id, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_version_links( + project_name, + version_id, + link_types, + link_direction + ) + + +def get_representations_links( + project_name, + representation_ids=None, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_representations_links( + project_name, + representation_ids, + link_types, + link_direction + ) + + +def get_representation_links( + project_name, + representation_id, + link_types=None, + link_direction=None +): + con = get_server_api_connection() + return con.get_representation_links( + project_name, + representation_id, + link_types, + link_direction + ) + + +def send_batch_operations( + project_name, + operations, + can_fail=False, + raise_on_fail=True +): + con = get_server_api_connection() + return con.send_batch_operations( + project_name, + operations, + can_fail=can_fail, + raise_on_fail=raise_on_fail + ) diff --git a/openpype/vendor/python/common/ayon_api/constants.py b/openpype/vendor/python/common/ayon_api/constants.py index e431af6f9d..03451756a0 100644 --- a/openpype/vendor/python/common/ayon_api/constants.py +++ b/openpype/vendor/python/common/ayon_api/constants.py @@ -1,5 +1,8 @@ +# Environments where server url and api key are stored for global connection SERVER_URL_ENV_KEY = "AYON_SERVER_URL" -SERVER_TOKEN_ENV_KEY = "AYON_TOKEN" +SERVER_API_ENV_KEY = "AYON_API_KEY" +# Backwards compatibility +SERVER_TOKEN_ENV_KEY = SERVER_API_ENV_KEY # --- Project --- DEFAULT_PROJECT_FIELDS = { @@ -102,4 +105,4 @@ DEFAULT_EVENT_FIELDS = { "topic", "updatedAt", "user", -} \ No newline at end of file +} diff --git a/openpype/vendor/python/common/ayon_api/entity_hub.py b/openpype/vendor/python/common/ayon_api/entity_hub.py index 76703d2e15..36489b6439 100644 --- a/openpype/vendor/python/common/ayon_api/entity_hub.py +++ b/openpype/vendor/python/common/ayon_api/entity_hub.py @@ -1,6 +1,6 @@ import copy import collections -from abc import ABCMeta, abstractmethod, abstractproperty +from abc import ABCMeta, abstractmethod import six from ._api import get_server_api_connection @@ -52,17 +52,35 @@ class EntityHub(object): @property def allow_data_changes(self): - """Entity hub allows changes of 'data' key on entities.""" + """Entity hub allows changes of 'data' key on entities. + + Data are private and not all users may have access to them. Also to get + 'data' for entity is required to use REST api calls, which means to + query each entity on-by-one from server. + + Returns: + bool: Data changes are allowed. + """ return self._allow_data_changes @property def project_name(self): + """Project name which is maintained by hub. + + Returns: + str: Name of project. + """ + return self._project_name @property def project_entity(self): - """Project entity.""" + """Project entity. + + Returns: + ProjectEntity: Project entity. + """ if self._project_entity is UNKNOWN_VALUE: self.fill_project_from_server() @@ -187,6 +205,12 @@ class EntityHub(object): @property def entities(self): + """Iterator over available entities. + + Returns: + Iterator[BaseEntity]: All queried/created entities cached in hub. + """ + for entity in self._entities_by_id.values(): yield entity @@ -194,8 +218,21 @@ class EntityHub(object): """Create folder object and add it to entity hub. Args: - parent (Union[ProjectEntity, FolderEntity]): Parent of added - folder. + folder_type (str): Type of folder. Folder type must be available in + config of project folder types. + entity_id (Union[str, None]): Id of the entity. New id is created if + not passed. + parent_id (Union[str, None]): Id of parent entity. + name (str): Name of entity. + label (Optional[str]): Folder label. + path (Optional[str]): Folder path. Path consist of all parent names + with slash('/') used as separator. + attribs (Dict[str, Any]): Attribute values. + data (Dict[str, Any]): Entity data (custom data). + thumbnail_id (Union[str, None]): Id of entity's thumbnail. + active (bool): Is entity active. + created (Optional[bool]): Entity is new. When 'None' is passed the + value is defined based on value of 'entity_id'. Returns: FolderEntity: Added folder entity. @@ -208,6 +245,27 @@ class EntityHub(object): return folder_entity def add_new_task(self, *args, created=True, **kwargs): + """Create folder object and add it to entity hub. + + Args: + task_type (str): Type of task. Task type must be available in + config of project folder types. + entity_id (Union[str, None]): Id of the entity. New id is created if + not passed. + parent_id (Union[str, None]): Id of parent entity. + name (str): Name of entity. + label (Optional[str]): Folder label. + attribs (Dict[str, Any]): Attribute values. + data (Dict[str, Any]): Entity data (custom data). + thumbnail_id (Union[str, None]): Id of entity's thumbnail. + active (bool): Is entity active. + created (Optional[bool]): Entity is new. When 'None' is passed the + value is defined based on value of 'entity_id'. + + Returns: + TaskEntity: Added task entity. + """ + task_entity = TaskEntity( *args, **kwargs, created=created, entity_hub=self ) @@ -428,7 +486,7 @@ class EntityHub(object): if parent_id is None: return - parent = self._entities_by_parent_id.get(parent_id) + parent = self._entities_by_id.get(parent_id) if parent is not None: parent.remove_child(entity.id) @@ -459,10 +517,12 @@ class EntityHub(object): reset_queue.append(child.id) def fill_project_from_server(self): - """Query project from server and create it's entity. + """Query project data from server and create project entity. + + This method will invalidate previous object of Project entity. Returns: - ProjectEntity: Entity that was created based on queried data. + ProjectEntity: Entity that was updated with server data. Raises: ValueError: When project was not found on server. @@ -844,17 +904,17 @@ class BaseEntity(object): entity are set as "current data" on server. Args: + entity_id (Union[str, None]): Id of the entity. New id is created if + not passed. + parent_id (Union[str, None]): Id of parent entity. name (str): Name of entity. attribs (Dict[str, Any]): Attribute values. data (Dict[str, Any]): Entity data (custom data). - parent_id (Union[str, None]): Id of parent entity. - entity_id (Union[str, None]): Id of the entity. New id is created if - not passed. thumbnail_id (Union[str, None]): Id of entity's thumbnail. active (bool): Is entity active. entity_hub (EntityHub): Object of entity hub which created object of the entity. - created (Union[bool, None]): Entity is new. When 'None' is passed the + created (Optional[bool]): Entity is new. When 'None' is passed the value is defined based on value of 'entity_id'. """ @@ -981,7 +1041,8 @@ class BaseEntity(object): return self._entity_hub.project_name - @abstractproperty + @property + @abstractmethod def entity_type(self): """Entity type coresponding to server. @@ -991,7 +1052,8 @@ class BaseEntity(object): pass - @abstractproperty + @property + @abstractmethod def parent_entity_types(self): """Entity type coresponding to server. @@ -1001,7 +1063,8 @@ class BaseEntity(object): pass - @abstractproperty + @property + @abstractmethod def changes(self): """Receive entity changes. @@ -1331,6 +1394,27 @@ class BaseEntity(object): class ProjectEntity(BaseEntity): + """Entity representing project on AYON server. + + Args: + project_code (str): Project code. + library (bool): Is project library project. + folder_types (list[dict[str, Any]]): Folder types definition. + task_types (list[dict[str, Any]]): Task types definition. + entity_id (Optional[str]): Id of the entity. New id is created if + not passed. + parent_id (Union[str, None]): Id of parent entity. + name (str): Name of entity. + attribs (Dict[str, Any]): Attribute values. + data (Dict[str, Any]): Entity data (custom data). + thumbnail_id (Union[str, None]): Id of entity's thumbnail. + active (bool): Is entity active. + entity_hub (EntityHub): Object of entity hub which created object of + the entity. + created (Optional[bool]): Entity is new. When 'None' is passed the + value is defined based on value of 'entity_id'. + """ + entity_type = "project" parent_entity_types = [] # TODO These are hardcoded but maybe should be used from server??? @@ -1433,6 +1517,28 @@ class ProjectEntity(BaseEntity): class FolderEntity(BaseEntity): + """Entity representing a folder on AYON server. + + Args: + folder_type (str): Type of folder. Folder type must be available in + config of project folder types. + entity_id (Union[str, None]): Id of the entity. New id is created if + not passed. + parent_id (Union[str, None]): Id of parent entity. + name (str): Name of entity. + attribs (Dict[str, Any]): Attribute values. + data (Dict[str, Any]): Entity data (custom data). + thumbnail_id (Union[str, None]): Id of entity's thumbnail. + active (bool): Is entity active. + label (Optional[str]): Folder label. + path (Optional[str]): Folder path. Path consist of all parent names + with slash('/') used as separator. + entity_hub (EntityHub): Object of entity hub which created object of + the entity. + created (Optional[bool]): Entity is new. When 'None' is passed the + value is defined based on value of 'entity_id'. + """ + entity_type = "folder" parent_entity_types = ["folder", "project"] @@ -1587,6 +1693,26 @@ class FolderEntity(BaseEntity): class TaskEntity(BaseEntity): + """Entity representing a task on AYON server. + + Args: + task_type (str): Type of task. Task type must be available in config + of project task types. + entity_id (Union[str, None]): Id of the entity. New id is created if + not passed. + parent_id (Union[str, None]): Id of parent entity. + name (str): Name of entity. + label (Optional[str]): Task label. + attribs (Dict[str, Any]): Attribute values. + data (Dict[str, Any]): Entity data (custom data). + thumbnail_id (Union[str, None]): Id of entity's thumbnail. + active (bool): Is entity active. + entity_hub (EntityHub): Object of entity hub which created object of + the entity. + created (Optional[bool]): Entity is new. When 'None' is passed the + value is defined based on value of 'entity_id'. + """ + entity_type = "task" parent_entity_types = ["folder"] diff --git a/openpype/vendor/python/common/ayon_api/events.py b/openpype/vendor/python/common/ayon_api/events.py index 1ea9331244..aa256f6cfc 100644 --- a/openpype/vendor/python/common/ayon_api/events.py +++ b/openpype/vendor/python/common/ayon_api/events.py @@ -49,4 +49,4 @@ class ServerEvent(object): "payload": self.payload, "finished": self.finished, "store": self.store - } \ No newline at end of file + } diff --git a/openpype/vendor/python/common/ayon_api/exceptions.py b/openpype/vendor/python/common/ayon_api/exceptions.py index 0ff09770b5..db4917e90a 100644 --- a/openpype/vendor/python/common/ayon_api/exceptions.py +++ b/openpype/vendor/python/common/ayon_api/exceptions.py @@ -33,6 +33,16 @@ class ServerNotReached(ServerError): pass +class RequestError(Exception): + def __init__(self, message, response): + self.response = response + super(RequestError, self).__init__(message) + + +class HTTPRequestError(RequestError): + pass + + class GraphQlQueryFailed(Exception): def __init__(self, errors, query, variables): if variables is None: @@ -94,4 +104,4 @@ class FailedOperations(Exception): class FailedServiceInit(Exception): - pass \ No newline at end of file + pass diff --git a/openpype/vendor/python/common/ayon_api/graphql.py b/openpype/vendor/python/common/ayon_api/graphql.py index 93349e9608..854f207a00 100644 --- a/openpype/vendor/python/common/ayon_api/graphql.py +++ b/openpype/vendor/python/common/ayon_api/graphql.py @@ -1,6 +1,6 @@ import copy import numbers -from abc import ABCMeta, abstractproperty, abstractmethod +from abc import ABCMeta, abstractmethod import six @@ -232,21 +232,31 @@ class GraphQlQuery: self._children.append(field) field.set_parent(self) - def add_field(self, name, has_edges=None): + def add_field_with_edges(self, name): + """Add field with edges to query. + + Args: + name (str): Field name e.g. 'tasks'. + + Returns: + GraphQlQueryEdgeField: Created field object. + """ + + item = GraphQlQueryEdgeField(name, self) + self.add_obj_field(item) + return item + + def add_field(self, name): """Add field to query. Args: name (str): Field name e.g. 'id'. - has_edges (bool): Field has edges so it need paging. Returns: - BaseGraphQlQueryField: Created field object. + GraphQlQueryField: Created field object. """ - if has_edges: - item = GraphQlQueryEdgeField(name, self) - else: - item = GraphQlQueryField(name, self) + item = GraphQlQueryField(name, self) self.add_obj_field(item) return item @@ -376,7 +386,6 @@ class BaseGraphQlQueryField(object): name (str): Name of field. parent (Union[BaseGraphQlQueryField, GraphQlQuery]): Parent object of a field. - has_edges (bool): Field has edges and should handle paging. """ def __init__(self, name, parent): @@ -401,6 +410,36 @@ class BaseGraphQlQueryField(object): def __repr__(self): return "<{} {}>".format(self.__class__.__name__, self.path) + def add_variable(self, key, value_type, value=None): + """Add variable to query. + + Args: + key (str): Variable name. + value_type (str): Type of expected value in variables. This is + graphql type e.g. "[String!]", "Int", "Boolean", etc. + value (Any): Default value for variable. Can be changed later. + + Returns: + QueryVariable: Created variable object. + + Raises: + KeyError: If variable was already added before. + """ + + return self._parent.add_variable(key, value_type, value) + + def get_variable(self, key): + """Variable object. + + Args: + key (str): Variable name added to headers. + + Returns: + QueryVariable: Variable object used in query string. + """ + + return self._parent.get_variable(key) + @property def need_query(self): """Still need query from server. @@ -414,11 +453,21 @@ class BaseGraphQlQueryField(object): if self._need_query: return True - for child in self._children: + for child in self._children_iter(): if child.need_query: return True return False + def _children_iter(self): + """Iterate over all children fields of object. + + Returns: + Iterator[BaseGraphQlQueryField]: Children fields. + """ + + for child in self._children: + yield child + def sum_edge_fields(self, max_limit=None): """Check how many edge fields query has. @@ -437,7 +486,7 @@ class BaseGraphQlQueryField(object): if isinstance(self, GraphQlQueryEdgeField): counter = 1 - for child in self._children: + for child in self._children_iter(): counter += child.sum_edge_fields(max_limit) if max_limit is not None and counter >= max_limit: break @@ -451,7 +500,8 @@ class BaseGraphQlQueryField(object): def indent(self): return self._parent.child_indent + self.offset - @abstractproperty + @property + @abstractmethod def child_indent(self): pass @@ -459,13 +509,14 @@ class BaseGraphQlQueryField(object): def query_item(self): return self._query_item - @abstractproperty + @property + @abstractmethod def has_edges(self): pass @property def child_has_edges(self): - for child in self._children: + for child in self._children_iter(): if child.has_edges or child.child_has_edges: return True return False @@ -487,7 +538,7 @@ class BaseGraphQlQueryField(object): return self._path def reset_cursor(self): - for child in self._children: + for child in self._children_iter(): child.reset_cursor() def get_variable_value(self, *args, **kwargs): @@ -518,11 +569,13 @@ class BaseGraphQlQueryField(object): self._children.append(field) field.set_parent(self) - def add_field(self, name, has_edges=None): - if has_edges: - item = GraphQlQueryEdgeField(name, self) - else: - item = GraphQlQueryField(name, self) + def add_field_with_edges(self, name): + item = GraphQlQueryEdgeField(name, self) + self.add_obj_field(item) + return item + + def add_field(self, name): + item = GraphQlQueryField(name, self) self.add_obj_field(item) return item @@ -580,7 +633,7 @@ class BaseGraphQlQueryField(object): def _fake_children_parse(self): """Mark children as they don't need query.""" - for child in self._children: + for child in self._children_iter(): child.parse_result({}, {}, {}) @abstractmethod @@ -673,12 +726,38 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField): def __init__(self, *args, **kwargs): super(GraphQlQueryEdgeField, self).__init__(*args, **kwargs) self._cursor = None + self._edge_children = [] @property def child_indent(self): offset = self.offset * 2 return self.indent + offset + def _children_iter(self): + for child in super(GraphQlQueryEdgeField, self)._children_iter(): + yield child + + for child in self._edge_children: + yield child + + def add_obj_field(self, field): + if field in self._edge_children: + return + + super(GraphQlQueryEdgeField, self).add_obj_field(field) + + def add_obj_edge_field(self, field): + if field in self._edge_children or field in self._children: + return + + self._edge_children.append(field) + field.set_parent(self) + + def add_edge_field(self, name): + item = GraphQlQueryField(name, self) + self.add_obj_edge_field(item) + return item + def reset_cursor(self): # Reset cursor only for edges self._cursor = None @@ -733,6 +812,9 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField): nodes_by_cursor[edge_cursor] = edge_value node_values.append(edge_value) + for child in self._edge_children: + child.parse_result(edge, edge_value, progress_data) + for child in self._children: child.parse_result(edge["node"], edge_value, progress_data) @@ -740,12 +822,12 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField): return change_cursor = True - for child in self._children: + for child in self._children_iter(): if child.need_query: change_cursor = False if change_cursor: - for child in self._children: + for child in self._children_iter(): child.reset_cursor() self._cursor = new_cursor @@ -761,7 +843,7 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField): return filters def calculate_query(self): - if not self._children: + if not self._children and not self._edge_children: raise ValueError("Missing child definitions for edges {}".format( self.path )) @@ -779,16 +861,21 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField): edges_offset = offset + self.offset * " " node_offset = edges_offset + self.offset * " " output.append(edges_offset + "edges {") - output.append(node_offset + "node {") + for field in self._edge_children: + output.append(field.calculate_query()) - for field in self._children: - output.append( - field.calculate_query() - ) + if self._children: + output.append(node_offset + "node {") + + for field in self._children: + output.append( + field.calculate_query() + ) + + output.append(node_offset + "}") + if self.child_has_edges: + output.append(node_offset + "cursor") - output.append(node_offset + "}") - if self.child_has_edges: - output.append(node_offset + "cursor") output.append(edges_offset + "}") # Add page information diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py index b6d5c5fcb3..4df377ea18 100644 --- a/openpype/vendor/python/common/ayon_api/graphql_queries.py +++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py @@ -25,6 +25,58 @@ def fields_to_dict(fields): return output +def add_links_fields(entity_field, nested_fields): + if "links" not in nested_fields: + return + links_fields = nested_fields.pop("links") + + link_edge_fields = { + "id", + "linkType", + "projectName", + "entityType", + "entityId", + "direction", + "description", + "author", + } + if isinstance(links_fields, dict): + simple_fields = set(links_fields) + simple_variant = len(simple_fields - link_edge_fields) == 0 + else: + simple_variant = True + simple_fields = link_edge_fields + + link_field = entity_field.add_field_with_edges("links") + + link_type_var = link_field.add_variable("linkTypes", "[String!]") + link_dir_var = link_field.add_variable("linkDirection", "String!") + link_field.set_filter("linkTypes", link_type_var) + link_field.set_filter("direction", link_dir_var) + + if simple_variant: + for key in simple_fields: + link_field.add_edge_field(key) + return + + query_queue = collections.deque() + for key, value in links_fields.items(): + if key in link_edge_fields: + link_field.add_edge_field(key) + continue + query_queue.append((key, value, link_field)) + + while query_queue: + item = query_queue.popleft() + key, value, parent = item + field = parent.add_field(key) + if value is FIELD_VALUE: + continue + + for k, v in value.items(): + query_queue.append((k, v, field)) + + def project_graphql_query(fields): query = GraphQlQuery("ProjectQuery") project_name_var = query.add_variable("projectName", "String!") @@ -51,7 +103,7 @@ def project_graphql_query(fields): def projects_graphql_query(fields): query = GraphQlQuery("ProjectsQuery") - projects_field = query.add_field("projects", has_edges=True) + projects_field = query.add_field_with_edges("projects") nested_fields = fields_to_dict(fields) @@ -83,7 +135,7 @@ def folders_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - folders_field = project_field.add_field("folders", has_edges=True) + folders_field = project_field.add_field_with_edges("folders") folders_field.set_filter("ids", folder_ids_var) folders_field.set_filter("parentIds", parent_folder_ids_var) folders_field.set_filter("names", folder_names_var) @@ -91,6 +143,7 @@ def folders_graphql_query(fields): folders_field.set_filter("hasSubsets", has_subsets_var) nested_fields = fields_to_dict(fields) + add_links_fields(folders_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): @@ -119,7 +172,7 @@ def tasks_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - tasks_field = project_field.add_field("tasks", has_edges=True) + tasks_field = project_field.add_field_with_edges("tasks") tasks_field.set_filter("ids", task_ids_var) # WARNING: At moment when this been created 'names' filter is not supported tasks_field.set_filter("names", task_names_var) @@ -127,6 +180,7 @@ def tasks_graphql_query(fields): tasks_field.set_filter("folderIds", folder_ids_var) nested_fields = fields_to_dict(fields) + add_links_fields(tasks_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): @@ -155,12 +209,13 @@ def subsets_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - subsets_field = project_field.add_field("subsets", has_edges=True) + subsets_field = project_field.add_field_with_edges("subsets") subsets_field.set_filter("ids", subset_ids_var) subsets_field.set_filter("names", subset_names_var) subsets_field.set_filter("folderIds", folder_ids_var) nested_fields = fields_to_dict(set(fields)) + add_links_fields(subsets_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): @@ -194,7 +249,7 @@ def versions_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - subsets_field = project_field.add_field("versions", has_edges=True) + subsets_field = project_field.add_field_with_edges("versions") subsets_field.set_filter("ids", version_ids_var) subsets_field.set_filter("subsetIds", subset_ids_var) subsets_field.set_filter("versions", versions_var) @@ -203,6 +258,7 @@ def versions_graphql_query(fields): subsets_field.set_filter("heroOrLatestOnly", hero_or_latest_only_var) nested_fields = fields_to_dict(set(fields)) + add_links_fields(subsets_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): @@ -231,12 +287,13 @@ def representations_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - repres_field = project_field.add_field("representations", has_edges=True) + repres_field = project_field.add_field_with_edges("representations") repres_field.set_filter("ids", repre_ids_var) repres_field.set_filter("versionIds", version_ids_var) repres_field.set_filter("names", repre_names_var) nested_fields = fields_to_dict(set(fields)) + add_links_fields(repres_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): @@ -266,7 +323,7 @@ def representations_parents_qraphql_query( project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - repres_field = project_field.add_field("representations", has_edges=True) + repres_field = project_field.add_field_with_edges("representations") repres_field.add_field("id") repres_field.set_filter("ids", repre_ids_var) version_field = repres_field.add_field("version") @@ -306,12 +363,13 @@ def workfiles_info_graphql_query(fields): project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - workfiles_field = project_field.add_field("workfiles", has_edges=True) + workfiles_field = project_field.add_field_with_edges("workfiles") workfiles_field.set_filter("ids", workfiles_info_ids) workfiles_field.set_filter("taskIds", task_ids_var) workfiles_field.set_filter("paths", paths_var) nested_fields = fields_to_dict(set(fields)) + add_links_fields(workfiles_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): @@ -337,7 +395,7 @@ def events_graphql_query(fields): users_var = query.add_variable("eventUsers", "[String!]") include_logs_var = query.add_variable("includeLogsFilter", "Boolean!") - events_field = query.add_field("events", has_edges=True) + events_field = query.add_field_with_edges("events") events_field.set_filter("topics", topics_var) events_field.set_filter("projects", projects_var) events_field.set_filter("states", states_var) diff --git a/openpype/vendor/python/common/ayon_api/operations.py b/openpype/vendor/python/common/ayon_api/operations.py index 21adc229d2..b5689de7c0 100644 --- a/openpype/vendor/python/common/ayon_api/operations.py +++ b/openpype/vendor/python/common/ayon_api/operations.py @@ -1,7 +1,7 @@ import copy import collections import uuid -from abc import ABCMeta, abstractproperty +from abc import ABCMeta, abstractmethod import six @@ -301,7 +301,8 @@ class AbstractOperation(object): def entity_type(self): return self._entity_type - @abstractproperty + @property + @abstractmethod def operation_name(self): """Stringified type of operation.""" diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index e3a42e4dad..8d52b484d8 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -46,6 +46,7 @@ from .exceptions import ( AuthenticationError, ServerNotReached, ServerError, + HTTPRequestError, ) from .utils import ( RepresentationParents, @@ -134,7 +135,10 @@ class RestApiResponse(object): return self.status def raise_for_status(self): - self._response.raise_for_status() + try: + self._response.raise_for_status() + except requests.exceptions.HTTPError as exc: + raise HTTPRequestError(str(exc), exc.response) def __enter__(self, *args, **kwargs): return self._response.__enter__(*args, **kwargs) @@ -143,9 +147,7 @@ class RestApiResponse(object): return key in self.data def __repr__(self): - return "<{}: {} ({})>".format( - self.__class__.__name__, self.status, self.detail - ) + return "<{} [{}]>".format(self.__class__.__name__, self.status) def __len__(self): return 200 <= self.status < 400 @@ -297,6 +299,14 @@ class ServerAPI(object): 'production'). """ + _entity_types_link_mapping = { + "folder": ("folderIds", "folders"), + "task": ("taskIds", "tasks"), + "subset": ("subsetIds", "subsets"), + "version": ("versionIds", "versions"), + "representation": ("representationIds", "representations"), + } + def __init__( self, base_url, @@ -1465,6 +1475,35 @@ class ServerAPI(object): response.raise_for_status() return response.data + def get_addon_url(self, addon_name, addon_version, *subpaths): + """Calculate url to addon route. + + Example: + >>> api = ServerAPI("https://your.url.com") + >>> api.get_addon_url( + ... "example", "1.0.0", "private", "my.zip") + 'https://your.url.com/addons/example/1.0.0/private/my.zip' + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + subpaths (tuple[str]): Any amount of subpaths that are added to + addon url. + + Returns: + str: Final url. + """ + + ending = "" + if subpaths: + ending = "/{}".format("/".join(subpaths)) + return "{}/addons/{}/{}{}".format( + self._base_url, + addon_name, + addon_version, + ending + ) + def download_addon_private_file( self, addon_name, @@ -1503,10 +1542,10 @@ class ServerAPI(object): if not os.path.exists(dst_dirpath): os.makedirs(dst_dirpath) - url = "{}/addons/{}/{}/private/{}".format( - self._base_url, + url = self.get_addon_url( addon_name, addon_version, + "private", filename ) self.download_file( @@ -1779,9 +1818,13 @@ class ServerAPI(object): dict[str, Any]: Schema of studio/project settings. """ - endpoint = "addons/{}/{}/schema".format(addon_name, addon_version) + args = tuple() if project_name: - endpoint += "/{}".format(project_name) + args = (project_name, ) + + endpoint = self.get_addon_url( + addon_name, addon_version, "schema", *args + ) result = self.get(endpoint) result.raise_for_status() return result.data @@ -2407,6 +2450,146 @@ class ServerAPI(object): fill_own_attribs(folder) yield folder + def get_folder_by_id( + self, + project_name, + folder_id, + fields=None, + own_attributes=False + ): + """Query folder entity by id. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_id (str): Folder id. + fields (Union[Iterable[str], None]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (bool): Attribute values that are not explicitly set + on entity will have 'None' value. + + Returns: + Union[dict, None]: Folder entity data or None if was not found. + """ + + folders = self.get_folders( + project_name, + folder_ids=[folder_id], + active=None, + fields=fields, + own_attributes=own_attributes + ) + for folder in folders: + return folder + return None + + def get_folder_by_path( + self, + project_name, + folder_path, + fields=None, + own_attributes=False + ): + """Query folder entity by path. + + Folder path is a path to folder with all parent names joined by slash. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_path (str): Folder path. + fields (Union[Iterable[str], None]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (bool): Attribute values that are not explicitly set + on entity will have 'None' value. + + Returns: + Union[dict, None]: Folder entity data or None if was not found. + """ + + folders = self.get_folders( + project_name, + folder_paths=[folder_path], + active=None, + fields=fields, + own_attributes=own_attributes + ) + for folder in folders: + return folder + return None + + def get_folder_by_name( + self, + project_name, + folder_name, + fields=None, + own_attributes=False + ): + """Query folder entity by path. + + Warnings: + Folder name is not a unique identifier of a folder. Function is + kept for OpenPype 3 compatibility. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_name (str): Folder name. + fields (Union[Iterable[str], None]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (bool): Attribute values that are not explicitly set + on entity will have 'None' value. + + Returns: + Union[dict, None]: Folder entity data or None if was not found. + """ + + folders = self.get_folders( + project_name, + folder_names=[folder_name], + active=None, + fields=fields, + own_attributes=own_attributes + ) + for folder in folders: + return folder + return None + + def get_folder_ids_with_subsets(self, project_name, folder_ids=None): + """Find folders which have at least one subset. + + Folders that have at least one subset should be immutable, so they + should not change path -> change of name or name of any parent + is not possible. + + Args: + project_name (str): Name of project. + folder_ids (Union[Iterable[str], None]): Limit folder ids filtering + to a set of folders. If set to None all folders on project are + checked. + + Returns: + set[str]: Folder ids that have at least one subset. + """ + + if folder_ids is not None: + folder_ids = set(folder_ids) + if not folder_ids: + return set() + + query = folders_graphql_query({"id"}) + query.set_variable_value("projectName", project_name) + query.set_variable_value("folderHasSubsets", True) + if folder_ids: + query.set_variable_value("folderIds", list(folder_ids)) + + parsed_data = query.query(self) + folders = parsed_data["project"]["folders"] + return { + folder["id"] + for folder in folders + } + def get_tasks( self, project_name, @@ -2569,147 +2752,6 @@ class ServerAPI(object): return task return None - - def get_folder_by_id( - self, - project_name, - folder_id, - fields=None, - own_attributes=False - ): - """Query folder entity by id. - - Args: - project_name (str): Name of project where to look for queried - entities. - folder_id (str): Folder id. - fields (Union[Iterable[str], None]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (bool): Attribute values that are not explicitly set - on entity will have 'None' value. - - Returns: - Union[dict, None]: Folder entity data or None if was not found. - """ - - folders = self.get_folders( - project_name, - folder_ids=[folder_id], - active=None, - fields=fields, - own_attributes=own_attributes - ) - for folder in folders: - return folder - return None - - def get_folder_by_path( - self, - project_name, - folder_path, - fields=None, - own_attributes=False - ): - """Query folder entity by path. - - Folder path is a path to folder with all parent names joined by slash. - - Args: - project_name (str): Name of project where to look for queried - entities. - folder_path (str): Folder path. - fields (Union[Iterable[str], None]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (bool): Attribute values that are not explicitly set - on entity will have 'None' value. - - Returns: - Union[dict, None]: Folder entity data or None if was not found. - """ - - folders = self.get_folders( - project_name, - folder_paths=[folder_path], - active=None, - fields=fields, - own_attributes=own_attributes - ) - for folder in folders: - return folder - return None - - def get_folder_by_name( - self, - project_name, - folder_name, - fields=None, - own_attributes=False - ): - """Query folder entity by path. - - Warnings: - Folder name is not a unique identifier of a folder. Function is - kept for OpenPype 3 compatibility. - - Args: - project_name (str): Name of project where to look for queried - entities. - folder_name (str): Folder name. - fields (Union[Iterable[str], None]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (bool): Attribute values that are not explicitly set - on entity will have 'None' value. - - Returns: - Union[dict, None]: Folder entity data or None if was not found. - """ - - folders = self.get_folders( - project_name, - folder_names=[folder_name], - active=None, - fields=fields, - own_attributes=own_attributes - ) - for folder in folders: - return folder - return None - - def get_folder_ids_with_subsets(self, project_name, folder_ids=None): - """Find folders which have at least one subset. - - Folders that have at least one subset should be immutable, so they - should not change path -> change of name or name of any parent - is not possible. - - Args: - project_name (str): Name of project. - folder_ids (Union[Iterable[str], None]): Limit folder ids filtering - to a set of folders. If set to None all folders on project are - checked. - - Returns: - set[str]: Folder ids that have at least one subset. - """ - - if folder_ids is not None: - folder_ids = set(folder_ids) - if not folder_ids: - return set() - - query = folders_graphql_query({"id"}) - query.set_variable_value("projectName", project_name) - query.set_variable_value("folderHasSubsets", True) - if folder_ids: - query.set_variable_value("folderIds", list(folder_ids)) - - parsed_data = query.query(self) - folders = parsed_data["project"]["folders"] - return { - folder["id"] - for folder in folders - } - def _filter_subset( self, project_name, subset, active, own_attributes, use_rest ): @@ -4021,6 +4063,97 @@ class ServerAPI(object): project_name, "workfile", workfile_id, thumbnail_id ) + def _get_thumbnail_mime_type(self, thumbnail_path): + """Get thumbnail mime type on thumbnail creation based on source path. + + Args: + thumbnail_path (str): Path to thumbnail source fie. + + Returns: + str: Mime type used for thumbnail creation. + + Raises: + ValueError: Mime type cannot be determined. + """ + + ext = os.path.splitext(thumbnail_path)[-1].lower() + if ext == ".png": + return "image/png" + + elif ext in (".jpeg", ".jpg"): + return "image/jpeg" + + raise ValueError( + "Thumbnail source file has unknown extensions {}".format(ext)) + + def create_thumbnail(self, project_name, src_filepath, thumbnail_id=None): + """Create new thumbnail on server from passed path. + + Args: + project_name (str): Project where the thumbnail will be created + and can be used. + src_filepath (str): Filepath to thumbnail which should be uploaded. + thumbnail_id (str): Prepared if of thumbnail. + + Returns: + str: Created thumbnail id. + + Raises: + ValueError: When thumbnail source cannot be processed. + """ + + if not os.path.exists(src_filepath): + raise ValueError("Entered filepath does not exist.") + + if thumbnail_id: + self.update_thumbnail( + project_name, + thumbnail_id, + src_filepath + ) + return thumbnail_id + + mime_type = self._get_thumbnail_mime_type(src_filepath) + with open(src_filepath, "rb") as stream: + content = stream.read() + + response = self.raw_post( + "projects/{}/thumbnails".format(project_name), + headers={"Content-Type": mime_type}, + data=content + ) + response.raise_for_status() + return response.data["id"] + + def update_thumbnail(self, project_name, thumbnail_id, src_filepath): + """Change thumbnail content by id. + + Update can be also used to create new thumbnail. + + Args: + project_name (str): Project where the thumbnail will be created + and can be used. + thumbnail_id (str): Thumbnail id to update. + src_filepath (str): Filepath to thumbnail which should be uploaded. + + Raises: + ValueError: When thumbnail source cannot be processed. + """ + + if not os.path.exists(src_filepath): + raise ValueError("Entered filepath does not exist.") + + mime_type = self._get_thumbnail_mime_type(src_filepath) + with open(src_filepath, "rb") as stream: + content = stream.read() + + response = self.raw_put( + "projects/{}/thumbnails/{}".format(project_name, thumbnail_id), + headers={"Content-Type": mime_type}, + data=content + ) + response.raise_for_status() + def create_project( self, project_name, @@ -4046,7 +4179,6 @@ class ServerAPI(object): library_project (bool): Project is library project. preset_name (str): Name of anatomy preset. Default is used if not passed. - con (ServerAPI): Connection to server with logged user. Raises: ValueError: When project name already exists. @@ -4107,55 +4239,562 @@ class ServerAPI(object): ) ) - def create_thumbnail(self, project_name, src_filepath): - """Create new thumbnail on server from passed path. + # --- Links --- + def get_full_link_type_name(self, link_type_name, input_type, output_type): + """Calculate full link type name used for query from server. Args: - project_name (str): Project where the thumbnail will be created - and can be used. - src_filepath (str): Filepath to thumbnail which should be uploaded. + link_type_name (str): Type of link. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. Returns: - str: Created thumbnail id. - - Todos: - Define more specific exceptions for thumbnail creation. - - Raises: - ValueError: When thumbnail creation fails (due to many reasons). + str: Full name of link type used for query from server. """ - if not os.path.exists(src_filepath): - raise ValueError("Entered filepath does not exist.") + return "|".join([link_type_name, input_type, output_type]) - ext = os.path.splitext(src_filepath)[-1].lower() - if ext == ".png": - mime_type = "image/png" + def get_link_types(self, project_name): + """All link types available on a project. - elif ext in (".jpeg", ".jpg"): - mime_type = "image/jpeg" + Example output: + [ + { + "name": "reference|folder|folder", + "link_type": "reference", + "input_type": "folder", + "output_type": "folder", + "data": {} + } + ] - else: - raise ValueError( - "Thumbnail source file has unknown extensions {}".format(ext)) + Args: + project_name (str): Name of project where to look for link types. - with open(src_filepath, "rb") as stream: - content = stream.read() + Returns: + list[dict[str, Any]]: Link types available on project. + """ - response = self.raw_post( - "projects/{}/thumbnails".format(project_name), - headers={"Content-Type": mime_type}, - data=content + response = self.get("projects/{}/links/types".format(project_name)) + response.raise_for_status() + return response.data["types"] + + def get_link_type( + self, project_name, link_type_name, input_type, output_type + ): + """Get link type data. + + There is not dedicated REST endpoint to get single link type, + so method 'get_link_types' is used. + + Example output: + { + "name": "reference|folder|folder", + "link_type": "reference", + "input_type": "folder", + "output_type": "folder", + "data": {} + } + + Args: + project_name (str): Project where link type is available. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + + Returns: + Union[None, dict[str, Any]]: Link type information. + """ + + full_type_name = self.get_full_link_type_name( + link_type_name, input_type, output_type ) - if response.status_code != 200: - _detail = response.data.get("detail") - details = "" - if _detail: - details = " {}".format(_detail) - raise ValueError( - "Failed to create thumbnail.{}".format(details)) - return response.data["id"] + for link_type in self.get_link_types(project_name): + if link_type["name"] == full_type_name: + return link_type + return None + def create_link_type( + self, project_name, link_type_name, input_type, output_type, data=None + ): + """Create or update link type on server. + + Warning: + Because PUT is used for creation it is also used for update. + + Args: + project_name (str): Project where link type is created. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + data (Optional[dict[str, Any]]): Additional data related to link. + + Raises: + HTTPRequestError: Server error happened. + """ + + if data is None: + data = {} + full_type_name = self.get_full_link_type_name( + link_type_name, input_type, output_type + ) + response = self.put( + "projects/{}/links/types/{}".format(project_name, full_type_name), + **data + ) + response.raise_for_status() + + def delete_link_type( + self, project_name, link_type_name, input_type, output_type + ): + """Remove link type from project. + + Args: + project_name (str): Project where link type is created. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + + Raises: + HTTPRequestError: Server error happened. + """ + + full_type_name = self.get_full_link_type_name( + link_type_name, input_type, output_type + ) + response = self.delete( + "projects/{}/links/types/{}".format(project_name, full_type_name)) + response.raise_for_status() + + def make_sure_link_type_exists( + self, project_name, link_type_name, input_type, output_type, data=None + ): + """Make sure link type exists on a project. + + Args: + project_name (str): Name of project. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + data (Optional[dict[str, Any]]): Link type related data. + """ + + link_type = self.get_link_type( + project_name, link_type_name, input_type, output_type) + if ( + link_type + and (data is None or data == link_type["data"]) + ): + return + self.create_link_type( + project_name, link_type_name, input_type, output_type, data + ) + + def create_link( + self, + project_name, + link_type_name, + input_id, + input_type, + output_id, + output_type + ): + """Create link between 2 entities. + + Link has a type which must already exists on a project. + + Example output: + { + "id": "59a212c0d2e211eda0e20242ac120002" + } + + Args: + project_name (str): Project where the link is created. + link_type_name (str): Type of link. + input_id (str): Id of input entity. + input_type (str): Entity type of input entity. + output_id (str): Id of output entity. + output_type (str): Entity type of output entity. + + Returns: + dict[str, str]: Information about link. + + Raises: + HTTPRequestError: Server error happened. + """ + + full_link_type_name = self.get_full_link_type_name( + link_type_name, input_type, output_type) + response = self.post( + "projects/{}/links".format(project_name), + link=full_link_type_name, + input=input_id, + output=output_id + ) + response.raise_for_status() + return response.data + + def delete_link(self, project_name, link_id): + """Remove link by id. + + Args: + project_name (str): Project where link exists. + link_id (str): Id of link. + + Raises: + HTTPRequestError: Server error happened. + """ + + response = self.delete( + "projects/{}/links/{}".format(project_name, link_id) + ) + response.raise_for_status() + + def _prepare_link_filters(self, filters, link_types, link_direction): + """Add links filters for GraphQl queries. + + Args: + filters (dict[str, Any]): Object where filters will be added. + link_types (Union[Iterable[str], None]): Link types filters. + link_direction (Union[Literal["in", "out"], None]): Direction of + link "in", "out" or 'None' for both. + + Returns: + bool: Links are valid, and query from server can happen. + """ + + if link_types is not None: + link_types = set(link_types) + if not link_types: + return False + filters["linkTypes"] = list(link_types) + + if link_direction is not None: + if link_direction not in ("in", "out"): + return False + filters["linkDirection"] = link_direction + return True + + def get_entities_links( + self, + project_name, + entity_type, + entity_ids=None, + link_types=None, + link_direction=None + ): + """Helper method to get links from server for entity types. + + Example output: + [ + { + "id": "59a212c0d2e211eda0e20242ac120002", + "linkType": "reference", + "description": "reference link between folders", + "projectName": "my_project", + "author": "frantadmin", + "entityId": "b1df109676db11ed8e8c6c9466b19aa8", + "entityType": "folder", + "direction": "out" + }, + ... + ] + + Args: + project_name (str): Project where links are. + entity_type (Literal["folder", "task", "subset", + "version", "representations"]): Entity type. + entity_ids (Union[Iterable[str], None]): Ids of entities for which + links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by entity ids. + """ + + mapped_type = self._entity_types_link_mapping.get(entity_type) + if not mapped_type: + raise ValueError("Unknown type \"{}\". Expected {}".format( + entity_type, ", ".join(self._entity_types_link_mapping.keys()) + )) + + id_filter_key, project_sub_key = mapped_type + output = collections.defaultdict(list) + filters = { + "projectName": project_name + } + if entity_ids is not None: + entity_ids = set(entity_ids) + if not entity_ids: + return output + filters[id_filter_key] = list(entity_ids) + + if not self._prepare_link_filters(filters, link_types, link_direction): + return output + + query = folders_graphql_query({"id", "links"}) + for attr, filter_value in filters.items(): + query.set_variable_value(attr, filter_value) + + for parsed_data in query.continuous_query(self): + for entity in parsed_data["project"][project_sub_key]: + entity_id = entity["id"] + output[entity_id].extend(entity["links"]) + return output + + def get_folders_links( + self, + project_name, + folder_ids=None, + link_types=None, + link_direction=None + ): + """Query folders links from server. + + Args: + project_name (str): Project where links are. + folder_ids (Union[Iterable[str], None]): Ids of folders for which + links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by folder ids. + """ + + return self.get_entities_links( + project_name, "folder", folder_ids, link_types, link_direction + ) + + def get_folder_links( + self, + project_name, + folder_id, + link_types=None, + link_direction=None + ): + """Query folder links from server. + + Args: + project_name (str): Project where links are. + folder_id (str): Id of folder for which links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of folder. + """ + + return self.get_folders_links( + project_name, [folder_id], link_types, link_direction + )[folder_id] + + def get_tasks_links( + self, + project_name, + task_ids=None, + link_types=None, + link_direction=None + ): + """Query tasks links from server. + + Args: + project_name (str): Project where links are. + task_ids (Union[Iterable[str], None]): Ids of tasks for which + links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by task ids. + """ + + return self.get_entities_links( + project_name, "task", task_ids, link_types, link_direction + ) + + def get_task_links( + self, + project_name, + task_id, + link_types=None, + link_direction=None + ): + """Query task links from server. + + Args: + project_name (str): Project where links are. + task_id (str): Id of task for which links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of task. + """ + + return self.get_tasks_links( + project_name, [task_id], link_types, link_direction + )[task_id] + + def get_subsets_links( + self, + project_name, + subset_ids=None, + link_types=None, + link_direction=None + ): + """Query subsets links from server. + + Args: + project_name (str): Project where links are. + subset_ids (Union[Iterable[str], None]): Ids of subsets for which + links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by subset ids. + """ + + return self.get_entities_links( + project_name, "subset", subset_ids, link_types, link_direction + ) + + def get_subset_links( + self, + project_name, + subset_id, + link_types=None, + link_direction=None + ): + """Query subset links from server. + + Args: + project_name (str): Project where links are. + subset_id (str): Id of subset for which links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of subset. + """ + + return self.get_subsets_links( + project_name, [subset_id], link_types, link_direction + )[subset_id] + + def get_versions_links( + self, + project_name, + version_ids=None, + link_types=None, + link_direction=None + ): + """Query versions links from server. + + Args: + project_name (str): Project where links are. + version_ids (Union[Iterable[str], None]): Ids of versions for which + links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by version ids. + """ + + return self.get_entities_links( + project_name, "version", version_ids, link_types, link_direction + ) + + def get_version_links( + self, + project_name, + version_id, + link_types=None, + link_direction=None + ): + """Query version links from server. + + Args: + project_name (str): Project where links are. + version_id (str): Id of version for which links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of version. + """ + + return self.get_versions_links( + project_name, [version_id], link_types, link_direction + )[version_id] + + def get_representations_links( + self, + project_name, + representation_ids=None, + link_types=None, + link_direction=None + ): + """Query representations links from server. + + Args: + project_name (str): Project where links are. + representation_ids (Union[Iterable[str], None]): Ids of + representations for which links should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by representation ids. + """ + + return self.get_entities_links( + project_name, + "representation", + representation_ids, + link_types, + link_direction + ) + + def get_representation_links( + self, + project_name, + representation_id, + link_types=None, + link_direction=None + ): + """Query representation links from server. + + Args: + project_name (str): Project where links are. + representation_id (str): Id of representation for which links + should be received. + link_types (Union[Iterable[str], None]): Link type filters. + link_direction (Union[Literal["in", "out"], None]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of representation. + """ + + return self.get_representations_links( + project_name, [representation_id], link_types, link_direction + )[representation_id] + + # --- Batch operations processing --- def send_batch_operations( self, project_name, diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index a65f885820..c8ddddd97e 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.1.16" \ No newline at end of file +__version__ = "0.1.17"