diff --git a/.gitignore b/.gitignore index e18c94a1f4..28cfb4b1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,3 @@ website/.docusaurus .poetry/ .python-version -.editorconfig -.pre-commit-config.yaml -mypy.ini diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index dff80e62b9..9964e3c646 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -519,7 +519,6 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", - "OPENPYPE_SG_USER", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", diff --git a/openpype/modules/shotgrid/README.md b/openpype/modules/shotgrid/README.md deleted file mode 100644 index cbee0e9bf4..0000000000 --- a/openpype/modules/shotgrid/README.md +++ /dev/null @@ -1,19 +0,0 @@ -## Shotgrid Module - -### Pre-requisites - -Install and launch a [shotgrid leecher](https://github.com/Ellipsanime/shotgrid-leecher) server - -### Quickstart - -The goal of this tutorial is to synchronize an already existing shotgrid project with OpenPype. - -- Activate the shotgrid module in the **system settings** and inform the shotgrid leecher server API url - -- Create a new OpenPype project with the **project manager** - -- Inform the shotgrid authentication infos (url, script name, api key) and the shotgrid project ID related to this OpenPype project in the **project settings** - -- Use the batch interface (Tray > shotgrid > Launch batch), select your project and click "batch" - -- You can now access your shotgrid entities within the **avalon launcher** and publish informations to shotgrid with **pyblish** diff --git a/openpype/modules/shotgrid/__init__.py b/openpype/modules/shotgrid/__init__.py deleted file mode 100644 index f1337a9492..0000000000 --- a/openpype/modules/shotgrid/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .shotgrid_module import ( - ShotgridModule, -) - -__all__ = ("ShotgridModule",) diff --git a/openpype/modules/shotgrid/lib/__init__.py b/openpype/modules/shotgrid/lib/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/modules/shotgrid/lib/const.py b/openpype/modules/shotgrid/lib/const.py deleted file mode 100644 index 2a34800fac..0000000000 --- a/openpype/modules/shotgrid/lib/const.py +++ /dev/null @@ -1 +0,0 @@ -MODULE_NAME = "shotgrid" diff --git a/openpype/modules/shotgrid/lib/credentials.py b/openpype/modules/shotgrid/lib/credentials.py deleted file mode 100644 index 337c4f6ecb..0000000000 --- a/openpype/modules/shotgrid/lib/credentials.py +++ /dev/null @@ -1,125 +0,0 @@ - -from urllib.parse import urlparse - -import shotgun_api3 -from shotgun_api3.shotgun import AuthenticationFault - -from openpype.lib import OpenPypeSecureRegistry, OpenPypeSettingsRegistry -from openpype.modules.shotgrid.lib.record import Credentials - - -def _get_shotgrid_secure_key(hostname, key): - """Secure item key for entered hostname.""" - return f"shotgrid/{hostname}/{key}" - - -def _get_secure_value_and_registry( - hostname, - name, -): - key = _get_shotgrid_secure_key(hostname, name) - registry = OpenPypeSecureRegistry(key) - return registry.get_item(name, None), registry - - -def get_shotgrid_hostname(shotgrid_url): - - if not shotgrid_url: - raise Exception("Shotgrid url cannot be a null") - valid_shotgrid_url = ( - f"//{shotgrid_url}" if "//" not in shotgrid_url else shotgrid_url - ) - return urlparse(valid_shotgrid_url).hostname - - -# Credentials storing function (using keyring) - - -def get_credentials(shotgrid_url): - hostname = get_shotgrid_hostname(shotgrid_url) - if not hostname: - return None - login_value, _ = _get_secure_value_and_registry( - hostname, - Credentials.login_key_prefix(), - ) - password_value, _ = _get_secure_value_and_registry( - hostname, - Credentials.password_key_prefix(), - ) - return Credentials(login_value, password_value) - - -def save_credentials(login, password, shotgrid_url): - hostname = get_shotgrid_hostname(shotgrid_url) - _, login_registry = _get_secure_value_and_registry( - hostname, - Credentials.login_key_prefix(), - ) - _, password_registry = _get_secure_value_and_registry( - hostname, - Credentials.password_key_prefix(), - ) - clear_credentials(shotgrid_url) - login_registry.set_item(Credentials.login_key_prefix(), login) - password_registry.set_item(Credentials.password_key_prefix(), password) - - -def clear_credentials(shotgrid_url): - hostname = get_shotgrid_hostname(shotgrid_url) - login_value, login_registry = _get_secure_value_and_registry( - hostname, - Credentials.login_key_prefix(), - ) - password_value, password_registry = _get_secure_value_and_registry( - hostname, - Credentials.password_key_prefix(), - ) - - if login_value is not None: - login_registry.delete_item(Credentials.login_key_prefix()) - - if password_value is not None: - password_registry.delete_item(Credentials.password_key_prefix()) - - -# Login storing function (using json) - - -def get_local_login(): - reg = OpenPypeSettingsRegistry() - try: - return str(reg.get_item("shotgrid_login")) - except Exception: - return None - - -def save_local_login(login): - reg = OpenPypeSettingsRegistry() - reg.set_item("shotgrid_login", login) - - -def clear_local_login(): - reg = OpenPypeSettingsRegistry() - reg.delete_item("shotgrid_login") - - -def check_credentials( - login, - password, - shotgrid_url, -): - - if not shotgrid_url or not login or not password: - return False - try: - session = shotgun_api3.Shotgun( - shotgrid_url, - login=login, - password=password, - ) - session.preferences_read() - session.close() - except AuthenticationFault: - return False - return True diff --git a/openpype/modules/shotgrid/lib/record.py b/openpype/modules/shotgrid/lib/record.py deleted file mode 100644 index f62f4855d5..0000000000 --- a/openpype/modules/shotgrid/lib/record.py +++ /dev/null @@ -1,20 +0,0 @@ - -class Credentials: - login = None - password = None - - def __init__(self, login, password) -> None: - super().__init__() - self.login = login - self.password = password - - def is_empty(self): - return not (self.login and self.password) - - @staticmethod - def login_key_prefix(): - return "login" - - @staticmethod - def password_key_prefix(): - return "password" diff --git a/openpype/modules/shotgrid/lib/settings.py b/openpype/modules/shotgrid/lib/settings.py deleted file mode 100644 index 924099f04b..0000000000 --- a/openpype/modules/shotgrid/lib/settings.py +++ /dev/null @@ -1,18 +0,0 @@ -from openpype.api import get_system_settings, get_project_settings -from openpype.modules.shotgrid.lib.const import MODULE_NAME - - -def get_shotgrid_project_settings(project): - return get_project_settings(project).get(MODULE_NAME, {}) - - -def get_shotgrid_settings(): - return get_system_settings().get("modules", {}).get(MODULE_NAME, {}) - - -def get_shotgrid_servers(): - return get_shotgrid_settings().get("shotgrid_settings", {}) - - -def get_leecher_backend_url(): - return get_shotgrid_settings().get("leecher_backend_url") diff --git a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py deleted file mode 100644 index 0b03ac2e5d..0000000000 --- a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py +++ /dev/null @@ -1,100 +0,0 @@ -import os - -import pyblish.api -from openpype.lib.mongo import OpenPypeMongoConnection - - -class CollectShotgridEntities(pyblish.api.ContextPlugin): - """Collect shotgrid entities according to the current context""" - - order = pyblish.api.CollectorOrder + 0.499 - label = "Shotgrid entities" - - def process(self, context): - - avalon_project = context.data.get("projectEntity") - avalon_asset = context.data.get("assetEntity") - avalon_task_name = os.getenv("AVALON_TASK") - - self.log.info(avalon_project) - self.log.info(avalon_asset) - - sg_project = _get_shotgrid_project(context) - sg_task = _get_shotgrid_task( - avalon_project, - avalon_asset, - avalon_task_name - ) - sg_entity = _get_shotgrid_entity(avalon_project, avalon_asset) - - if sg_project: - context.data["shotgridProject"] = sg_project - self.log.info( - "Collected correspondig shotgrid project : {}".format( - sg_project - ) - ) - - if sg_task: - context.data["shotgridTask"] = sg_task - self.log.info( - "Collected correspondig shotgrid task : {}".format(sg_task) - ) - - if sg_entity: - context.data["shotgridEntity"] = sg_entity - self.log.info( - "Collected correspondig shotgrid entity : {}".format(sg_entity) - ) - - def _find_existing_version(self, code, context): - - filters = [ - ["project", "is", context.data.get("shotgridProject")], - ["sg_task", "is", context.data.get("shotgridTask")], - ["entity", "is", context.data.get("shotgridEntity")], - ["code", "is", code], - ] - - sg = context.data.get("shotgridSession") - return sg.find_one("Version", filters, []) - - -def _get_shotgrid_collection(project): - client = OpenPypeMongoConnection.get_mongo_client() - return client.get_database("shotgrid_openpype").get_collection(project) - - -def _get_shotgrid_project(context): - shotgrid_project_id = context.data["project_settings"].get( - "shotgrid_project_id") - if shotgrid_project_id: - return {"type": "Project", "id": shotgrid_project_id} - return {} - - -def _get_shotgrid_task(avalon_project, avalon_asset, avalon_task): - sg_col = _get_shotgrid_collection(avalon_project["name"]) - shotgrid_task_hierarchy_row = sg_col.find_one( - { - "type": "Task", - "_id": {"$regex": "^" + avalon_task + "_[0-9]*"}, - "parent": {"$regex": ".*," + avalon_asset["name"] + ","}, - } - ) - if shotgrid_task_hierarchy_row: - return {"type": "Task", "id": shotgrid_task_hierarchy_row["src_id"]} - return {} - - -def _get_shotgrid_entity(avalon_project, avalon_asset): - sg_col = _get_shotgrid_collection(avalon_project["name"]) - shotgrid_entity_hierarchy_row = sg_col.find_one( - {"_id": avalon_asset["name"]} - ) - if shotgrid_entity_hierarchy_row: - return { - "type": shotgrid_entity_hierarchy_row["type"], - "id": shotgrid_entity_hierarchy_row["src_id"], - } - return {} diff --git a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py deleted file mode 100644 index 9d5d2271bf..0000000000 --- a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py +++ /dev/null @@ -1,123 +0,0 @@ -import os - -import pyblish.api -import shotgun_api3 -from shotgun_api3.shotgun import AuthenticationFault - -from openpype.lib import OpenPypeSettingsRegistry -from openpype.modules.shotgrid.lib.settings import ( - get_shotgrid_servers, - get_shotgrid_project_settings, -) - - -class CollectShotgridSession(pyblish.api.ContextPlugin): - """Collect shotgrid session using user credentials""" - - order = pyblish.api.CollectorOrder - label = "Shotgrid user session" - - def process(self, context): - - certificate_path = os.getenv("SHOTGUN_API_CACERTS") - if certificate_path is None or not os.path.exists(certificate_path): - self.log.info( - "SHOTGUN_API_CACERTS does not contains a valid \ - path: {}".format( - certificate_path - ) - ) - certificate_path = get_shotgrid_certificate() - self.log.info("Get Certificate from shotgrid_api") - - if not os.path.exists(certificate_path): - self.log.error( - "Could not find certificate in shotgun_api3: \ - {}".format( - certificate_path - ) - ) - return - - set_shotgrid_certificate(certificate_path) - self.log.info("Set Certificate: {}".format(certificate_path)) - - avalon_project = os.getenv("AVALON_PROJECT") - - shotgrid_settings = get_shotgrid_project_settings(avalon_project) - self.log.info("shotgrid settings: {}".format(shotgrid_settings)) - shotgrid_servers_settings = get_shotgrid_servers() - self.log.info( - "shotgrid_servers_settings: {}".format(shotgrid_servers_settings) - ) - - shotgrid_server = shotgrid_settings.get("shotgrid_server", "") - if not shotgrid_server: - self.log.error( - "No Shotgrid server found, please choose a credential" - "in script name and script key in OpenPype settings" - ) - - shotgrid_server_setting = shotgrid_servers_settings.get( - shotgrid_server, {} - ) - shotgrid_url = shotgrid_server_setting.get("shotgrid_url", "") - - shotgrid_script_name = shotgrid_server_setting.get( - "shotgrid_script_name", "" - ) - shotgrid_script_key = shotgrid_server_setting.get( - "shotgrid_script_key", "" - ) - if not shotgrid_script_name and not shotgrid_script_key: - self.log.error( - "No Shotgrid api credential found, please enter " - "script name and script key in OpenPype settings" - ) - - login = get_login() or os.getenv("OPENPYPE_SG_USER") - - if not login: - self.log.error( - "No Shotgrid login found, please " - "login to shotgrid withing openpype Tray" - ) - - session = shotgun_api3.Shotgun( - base_url=shotgrid_url, - script_name=shotgrid_script_name, - api_key=shotgrid_script_key, - sudo_as_login=login, - ) - - try: - session.preferences_read() - except AuthenticationFault: - raise ValueError( - "Could not connect to shotgrid {} with user {}".format( - shotgrid_url, login - ) - ) - - self.log.info( - "Logged to shotgrid {} with user {}".format(shotgrid_url, login) - ) - context.data["shotgridSession"] = session - context.data["shotgridUser"] = login - - -def get_shotgrid_certificate(): - shotgun_api_path = os.path.dirname(shotgun_api3.__file__) - return os.path.join(shotgun_api_path, "lib", "certifi", "cacert.pem") - - -def set_shotgrid_certificate(certificate): - os.environ["SHOTGUN_API_CACERTS"] = certificate - - -def get_login(): - reg = OpenPypeSettingsRegistry() - try: - return str(reg.get_item("shotgrid_login")) - except Exception: - return None diff --git a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py deleted file mode 100644 index cfd2d10fd9..0000000000 --- a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py +++ /dev/null @@ -1,77 +0,0 @@ -import os -import pyblish.api - - -class IntegrateShotgridPublish(pyblish.api.InstancePlugin): - """ - Create published Files from representations and add it to version. If - representation is tagged add shotgrid review, it will add it in - path to movie for a movie file or path to frame for an image sequence. - """ - - order = pyblish.api.IntegratorOrder + 0.499 - label = "Shotgrid Published Files" - - def process(self, instance): - - context = instance.context - - self.sg = context.data.get("shotgridSession") - - shotgrid_version = instance.data.get("shotgridVersion") - - for representation in instance.data.get("representations", []): - - local_path = representation.get("published_path") - code = os.path.basename(local_path) - - if representation.get("tags", []): - continue - - published_file = self._find_existing_publish( - code, context, shotgrid_version - ) - - published_file_data = { - "project": context.data.get("shotgridProject"), - "code": code, - "entity": context.data.get("shotgridEntity"), - "task": context.data.get("shotgridTask"), - "version": shotgrid_version, - "path": {"local_path": local_path}, - } - if not published_file: - published_file = self._create_published(published_file_data) - self.log.info( - "Create Shotgrid PublishedFile: {}".format(published_file) - ) - else: - self.sg.update( - published_file["type"], - published_file["id"], - published_file_data, - ) - self.log.info( - "Update Shotgrid PublishedFile: {}".format(published_file) - ) - - if instance.data["family"] == "image": - self.sg.upload_thumbnail( - published_file["type"], published_file["id"], local_path - ) - instance.data["shotgridPublishedFile"] = published_file - - def _find_existing_publish(self, code, context, shotgrid_version): - - filters = [ - ["project", "is", context.data.get("shotgridProject")], - ["task", "is", context.data.get("shotgridTask")], - ["entity", "is", context.data.get("shotgridEntity")], - ["version", "is", shotgrid_version], - ["code", "is", code], - ] - return self.sg.find_one("PublishedFile", filters, []) - - def _create_published(self, published_file_data): - - return self.sg.create("PublishedFile", published_file_data) diff --git a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py deleted file mode 100644 index a1b7140e22..0000000000 --- a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import pyblish.api - - -class IntegrateShotgridVersion(pyblish.api.InstancePlugin): - """Integrate Shotgrid Version""" - - order = pyblish.api.IntegratorOrder + 0.497 - label = "Shotgrid Version" - - sg = None - - def process(self, instance): - - context = instance.context - self.sg = context.data.get("shotgridSession") - - # TODO: Use path template solver to build version code from settings - anatomy = instance.data.get("anatomyData", {}) - code = "_".join( - [ - anatomy["project"]["code"], - anatomy["parent"], - anatomy["asset"], - anatomy["task"]["name"], - "v{:03}".format(int(anatomy["version"])), - ] - ) - - version = self._find_existing_version(code, context) - - if not version: - version = self._create_version(code, context) - self.log.info("Create Shotgrid version: {}".format(version)) - else: - self.log.info("Use existing Shotgrid version: {}".format(version)) - - data_to_update = {} - status = context.data.get("intent", {}).get("value") - if status: - data_to_update["sg_status_list"] = status - - for representation in instance.data.get("representations", []): - local_path = representation.get("published_path") - code = os.path.basename(local_path) - - if "shotgridreview" in representation.get("tags", []): - - if representation["ext"] in ["mov", "avi"]: - self.log.info( - "Upload review: {} for version shotgrid {}".format( - local_path, version.get("id") - ) - ) - self.sg.upload( - "Version", - version.get("id"), - local_path, - field_name="sg_uploaded_movie", - ) - - data_to_update["sg_path_to_movie"] = local_path - - elif representation["ext"] in ["jpg", "png", "exr", "tga"]: - path_to_frame = local_path.replace("0000", "#") - data_to_update["sg_path_to_frames"] = path_to_frame - - self.log.info("Update Shotgrid version with {}".format(data_to_update)) - self.sg.update("Version", version["id"], data_to_update) - - instance.data["shotgridVersion"] = version - - def _find_existing_version(self, code, context): - - filters = [ - ["project", "is", context.data.get("shotgridProject")], - ["sg_task", "is", context.data.get("shotgridTask")], - ["entity", "is", context.data.get("shotgridEntity")], - ["code", "is", code], - ] - return self.sg.find_one("Version", filters, []) - - def _create_version(self, code, context): - - version_data = { - "project": context.data.get("shotgridProject"), - "sg_task": context.data.get("shotgridTask"), - "entity": context.data.get("shotgridEntity"), - "code": code, - } - - return self.sg.create("Version", version_data) diff --git a/openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py b/openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py deleted file mode 100644 index c14c980e2a..0000000000 --- a/openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py +++ /dev/null @@ -1,38 +0,0 @@ -import pyblish.api -import openpype.api - - -class ValidateShotgridUser(pyblish.api.ContextPlugin): - """ - Check if user is valid and have access to the project. - """ - - label = "Validate Shotgrid User" - order = openpype.api.ValidateContentsOrder - - def process(self, context): - sg = context.data.get("shotgridSession") - - login = context.data.get("shotgridUser") - self.log.info("Login shotgrid set in OpenPype is {}".format(login)) - project = context.data.get("shotgridProject") - self.log.info("Current shotgun project is {}".format(project)) - - if not (login and sg and project): - raise KeyError() - - user = sg.find_one("HumanUser", [["login", "is", login]], ["projects"]) - - self.log.info(user) - self.log.info(login) - user_projects_id = [p["id"] for p in user.get("projects", [])] - if not project.get("id") in user_projects_id: - raise PermissionError( - "Login {} don't have access to the project {}".format( - login, project - ) - ) - - self.log.info( - "Login {} have access to the project {}".format(login, project) - ) diff --git a/openpype/modules/shotgrid/server/README.md b/openpype/modules/shotgrid/server/README.md deleted file mode 100644 index 15e056ff3e..0000000000 --- a/openpype/modules/shotgrid/server/README.md +++ /dev/null @@ -1,5 +0,0 @@ - -### Shotgrid server - -Please refer to the external project that covers Openpype/Shotgrid communication: - - https://github.com/Ellipsanime/shotgrid-leecher diff --git a/openpype/modules/shotgrid/shotgrid_module.py b/openpype/modules/shotgrid/shotgrid_module.py deleted file mode 100644 index 5644f0c35f..0000000000 --- a/openpype/modules/shotgrid/shotgrid_module.py +++ /dev/null @@ -1,58 +0,0 @@ -import os - -from openpype_interfaces import ( - ITrayModule, - IPluginPaths, - ILaunchHookPaths, -) - -from openpype.modules import OpenPypeModule - -SHOTGRID_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) - - -class ShotgridModule( - OpenPypeModule, ITrayModule, IPluginPaths, ILaunchHookPaths -): - leecher_manager_url = None - name = "shotgrid" - enabled = False - project_id = None - tray_wrapper = None - - def initialize(self, modules_settings): - shotgrid_settings = modules_settings.get(self.name, dict()) - self.enabled = shotgrid_settings.get("enabled", False) - self.leecher_manager_url = shotgrid_settings.get( - "leecher_manager_url", "" - ) - - def connect_with_modules(self, enabled_modules): - pass - - def get_global_environments(self): - return {"PROJECT_ID": self.project_id} - - def get_plugin_paths(self): - return { - "publish": [ - os.path.join(SHOTGRID_MODULE_DIR, "plugins", "publish") - ] - } - - def get_launch_hook_paths(self): - return os.path.join(SHOTGRID_MODULE_DIR, "hooks") - - def tray_init(self): - from .tray.shotgrid_tray import ShotgridTrayWrapper - - self.tray_wrapper = ShotgridTrayWrapper(self) - - def tray_start(self): - return self.tray_wrapper.validate() - - def tray_exit(self, *args, **kwargs): - return self.tray_wrapper - - def tray_menu(self, tray_menu): - return self.tray_wrapper.tray_menu(tray_menu) diff --git a/openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py b/openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py deleted file mode 100644 index 1f78cf77c9..0000000000 --- a/openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest -from assertpy import assert_that - -import openpype.modules.shotgrid.lib.credentials as sut - - -def test_missing_shotgrid_url(): - with pytest.raises(Exception) as ex: - # arrange - url = "" - # act - sut.get_shotgrid_hostname(url) - # assert - assert_that(ex).is_equal_to("Shotgrid url cannot be a null") - - -def test_full_shotgrid_url(): - # arrange - url = "https://shotgrid.com/myinstance" - # act - actual = sut.get_shotgrid_hostname(url) - # assert - assert_that(actual).is_not_empty() - assert_that(actual).is_equal_to("shotgrid.com") - - -def test_incomplete_shotgrid_url(): - # arrange - url = "shotgrid.com/myinstance" - # act - actual = sut.get_shotgrid_hostname(url) - # assert - assert_that(actual).is_not_empty() - assert_that(actual).is_equal_to("shotgrid.com") diff --git a/openpype/modules/shotgrid/tray/credential_dialog.py b/openpype/modules/shotgrid/tray/credential_dialog.py deleted file mode 100644 index 9d841d98be..0000000000 --- a/openpype/modules/shotgrid/tray/credential_dialog.py +++ /dev/null @@ -1,201 +0,0 @@ -import os -from Qt import QtCore, QtWidgets, QtGui - -from openpype import style -from openpype import resources -from openpype.modules.shotgrid.lib import settings, credentials - - -class CredentialsDialog(QtWidgets.QDialog): - SIZE_W = 450 - SIZE_H = 200 - - _module = None - _is_logged = False - url_label = None - login_label = None - password_label = None - url_input = None - login_input = None - password_input = None - input_layout = None - login_button = None - buttons_layout = None - main_widget = None - - login_changed = QtCore.Signal() - - def __init__(self, module, parent=None): - super(CredentialsDialog, self).__init__(parent) - - self._module = module - self._is_logged = False - - self.setWindowTitle("OpenPype - Shotgrid Login") - - icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) - self.setWindowIcon(icon) - - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint - | QtCore.Qt.WindowMinimizeButtonHint - ) - self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) - self.setMaximumSize(QtCore.QSize(self.SIZE_W + 100, self.SIZE_H + 100)) - self.setStyleSheet(style.load_stylesheet()) - - self.ui_init() - - def ui_init(self): - self.url_label = QtWidgets.QLabel("Shotgrid server:") - self.login_label = QtWidgets.QLabel("Login:") - self.password_label = QtWidgets.QLabel("Password:") - - self.url_input = QtWidgets.QComboBox() - # self.url_input.setReadOnly(True) - - self.login_input = QtWidgets.QLineEdit() - self.login_input.setPlaceholderText("login") - - self.password_input = QtWidgets.QLineEdit() - self.password_input.setPlaceholderText("password") - self.password_input.setEchoMode(QtWidgets.QLineEdit.Password) - - self.error_label = QtWidgets.QLabel("") - self.error_label.setStyleSheet("color: red;") - self.error_label.setWordWrap(True) - self.error_label.hide() - - self.input_layout = QtWidgets.QFormLayout() - self.input_layout.setContentsMargins(10, 15, 10, 5) - - self.input_layout.addRow(self.url_label, self.url_input) - self.input_layout.addRow(self.login_label, self.login_input) - self.input_layout.addRow(self.password_label, self.password_input) - self.input_layout.addRow(self.error_label) - - self.login_button = QtWidgets.QPushButton("Login") - self.login_button.setToolTip("Log in shotgrid instance") - self.login_button.clicked.connect(self._on_shotgrid_login_clicked) - - self.logout_button = QtWidgets.QPushButton("Logout") - self.logout_button.setToolTip("Log out shotgrid instance") - self.logout_button.clicked.connect(self._on_shotgrid_logout_clicked) - - self.buttons_layout = QtWidgets.QHBoxLayout() - self.buttons_layout.addWidget(self.logout_button) - self.buttons_layout.addWidget(self.login_button) - - self.main_widget = QtWidgets.QVBoxLayout(self) - self.main_widget.addLayout(self.input_layout) - self.main_widget.addLayout(self.buttons_layout) - self.setLayout(self.main_widget) - - def show(self, *args, **kwargs): - super(CredentialsDialog, self).show(*args, **kwargs) - self._fill_shotgrid_url() - self._fill_shotgrid_login() - - def _fill_shotgrid_url(self): - servers = settings.get_shotgrid_servers() - - if servers: - for _, v in servers.items(): - self.url_input.addItem("{}".format(v.get('shotgrid_url'))) - self._valid_input(self.url_input) - self.login_button.show() - self.logout_button.show() - enabled = True - else: - self.set_error("Ask your admin to add shotgrid server in settings") - self._invalid_input(self.url_input) - self.login_button.hide() - self.logout_button.hide() - enabled = False - - self.login_input.setEnabled(enabled) - self.password_input.setEnabled(enabled) - - def _fill_shotgrid_login(self): - login = credentials.get_local_login() - - if login: - self.login_input.setText(login) - - def _clear_shotgrid_login(self): - self.login_input.setText("") - self.password_input.setText("") - - def _on_shotgrid_login_clicked(self): - login = self.login_input.text().strip() - password = self.password_input.text().strip() - missing = [] - - if login == "": - missing.append("login") - self._invalid_input(self.login_input) - - if password == "": - missing.append("password") - self._invalid_input(self.password_input) - - url = self.url_input.currentText() - if url == "": - missing.append("url") - self._invalid_input(self.url_input) - - if len(missing) > 0: - self.set_error("You didn't enter {}".format(" and ".join(missing))) - return - - # if credentials.check_credentials( - # login=login, - # password=password, - # shotgrid_url=url, - # ): - credentials.save_local_login( - login=login - ) - os.environ['OPENPYPE_SG_USER'] = login - self._on_login() - - self.set_error("CANT LOGIN") - - def _on_shotgrid_logout_clicked(self): - credentials.clear_local_login() - del os.environ['OPENPYPE_SG_USER'] - self._clear_shotgrid_login() - self._on_logout() - - def set_error(self, msg): - self.error_label.setText(msg) - self.error_label.show() - - def _on_login(self): - self._is_logged = True - self.login_changed.emit() - self._close_widget() - - def _on_logout(self): - self._is_logged = False - self.login_changed.emit() - - def _close_widget(self): - self.hide() - - def _valid_input(self, input_widget): - input_widget.setStyleSheet("") - - def _invalid_input(self, input_widget): - input_widget.setStyleSheet("border: 1px solid red;") - - def login_with_credentials( - self, url, login, password - ): - verification = credentials.check_credentials(url, login, password) - if verification: - credentials.save_credentials(login, password, False) - self._module.set_credentials_to_env(login, password) - self.set_credentials(login, password) - self.login_changed.emit() - return verification diff --git a/openpype/modules/shotgrid/tray/shotgrid_tray.py b/openpype/modules/shotgrid/tray/shotgrid_tray.py deleted file mode 100644 index 4038d77b03..0000000000 --- a/openpype/modules/shotgrid/tray/shotgrid_tray.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import webbrowser - -from Qt import QtWidgets - -from openpype.modules.shotgrid.lib import credentials -from openpype.modules.shotgrid.tray.credential_dialog import ( - CredentialsDialog, -) - - -class ShotgridTrayWrapper: - module = None - credentials_dialog = None - logged_user_label = None - - def __init__(self, module): - self.module = module - self.credentials_dialog = CredentialsDialog(module) - self.credentials_dialog.login_changed.connect(self.set_login_label) - self.logged_user_label = QtWidgets.QAction("") - self.logged_user_label.setDisabled(True) - self.set_login_label() - - def show_batch_dialog(self): - if self.module.leecher_manager_url: - webbrowser.open(self.module.leecher_manager_url) - - def show_connect_dialog(self): - self.show_credential_dialog() - - def show_credential_dialog(self): - self.credentials_dialog.show() - self.credentials_dialog.activateWindow() - self.credentials_dialog.raise_() - - def set_login_label(self): - login = credentials.get_local_login() - if login: - self.logged_user_label.setText("{}".format(login)) - else: - self.logged_user_label.setText( - "No User logged in {0}".format(login) - ) - - def tray_menu(self, tray_menu): - # Add login to user menu - menu = QtWidgets.QMenu("Shotgrid", tray_menu) - show_connect_action = QtWidgets.QAction("Connect to Shotgrid", menu) - show_connect_action.triggered.connect(self.show_connect_dialog) - menu.addAction(self.logged_user_label) - menu.addSeparator() - menu.addAction(show_connect_action) - tray_menu.addMenu(menu) - - # Add manager to Admin menu - for m in tray_menu.findChildren(QtWidgets.QMenu): - if m.title() == "Admin": - shotgrid_manager_action = QtWidgets.QAction( - "Shotgrid manager", menu - ) - shotgrid_manager_action.triggered.connect( - self.show_batch_dialog - ) - m.addAction(shotgrid_manager_action) - - def validate(self): - login = credentials.get_local_login() - - if not login: - self.show_credential_dialog() - else: - os.environ["OPENPYPE_SG_USER"] = login - - return True diff --git a/openpype/resources/app_icons/shotgrid.png b/openpype/resources/app_icons/shotgrid.png deleted file mode 100644 index 6d0cc047f9..0000000000 Binary files a/openpype/resources/app_icons/shotgrid.png and /dev/null differ diff --git a/openpype/settings/defaults/project_settings/shotgrid.json b/openpype/settings/defaults/project_settings/shotgrid.json deleted file mode 100644 index 83b6f69074..0000000000 --- a/openpype/settings/defaults/project_settings/shotgrid.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "shotgrid_project_id": 0, - "shotgrid_server": "", - "event": { - "enabled": false - }, - "fields": { - "asset": { - "type": "sg_asset_type" - }, - "sequence": { - "episode_link": "episode" - }, - "shot": { - "episode_link": "sg_episode", - "sequence_link": "sg_sequence" - }, - "task": { - "step": "step" - } - } -} diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 9d8910689a..8cd4114cb0 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -131,12 +131,6 @@ } } }, - "shotgrid": { - "enabled": false, - "leecher_manager_url": "http://127.0.0.1:3000", - "leecher_backend_url": "http://127.0.0.1:8090", - "shotgrid_settings": {} - }, "kitsu": { "enabled": false, "server": "" @@ -209,4 +203,4 @@ "linux": "" } } -} +} \ No newline at end of file diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index b2cb2204f4..a173e2454f 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -107,7 +107,6 @@ from .enum_entity import ( TaskTypeEnumEntity, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity, - ShotgridUrlEnumEntity ) from .list_entity import ListEntity @@ -172,7 +171,6 @@ __all__ = ( "ToolsEnumEntity", "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", - "ShotgridUrlEnumEntity", "AnatomyTemplatesEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 3b3dd47e61..92a397afba 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,7 +1,10 @@ import copy from .input_entities import InputEntity from .exceptions import EntitySchemaError -from .lib import NOT_SET, STRING_TYPE +from .lib import ( + NOT_SET, + STRING_TYPE +) class BaseEnumEntity(InputEntity): @@ -23,7 +26,7 @@ class BaseEnumEntity(InputEntity): for item in self.enum_items: key = tuple(item.keys())[0] if key in enum_keys: - reason = 'Key "{}" is more than once in enum items.'.format( + reason = "Key \"{}\" is more than once in enum items.".format( key ) raise EntitySchemaError(self, reason) @@ -31,7 +34,7 @@ class BaseEnumEntity(InputEntity): enum_keys.add(key) if not isinstance(key, STRING_TYPE): - reason = 'Key "{}" has invalid type {}, expected {}.'.format( + reason = "Key \"{}\" has invalid type {}, expected {}.".format( key, type(key), STRING_TYPE ) raise EntitySchemaError(self, reason) @@ -56,7 +59,7 @@ class BaseEnumEntity(InputEntity): for item in check_values: if item not in self.valid_keys: raise ValueError( - '{} Invalid value "{}". Expected one of: {}'.format( + "{} Invalid value \"{}\". Expected one of: {}".format( self.path, item, self.valid_keys ) ) @@ -81,7 +84,7 @@ class EnumEntity(BaseEnumEntity): self.valid_keys = set(all_keys) if self.multiselection: - self.valid_value_types = (list,) + self.valid_value_types = (list, ) value_on_not_set = [] if enum_default: if not isinstance(enum_default, list): @@ -106,7 +109,7 @@ class EnumEntity(BaseEnumEntity): self.value_on_not_set = key break - self.valid_value_types = (STRING_TYPE,) + self.valid_value_types = (STRING_TYPE, ) # GUI attribute self.placeholder = self.schema_data.get("placeholder") @@ -149,7 +152,6 @@ class HostsEnumEntity(BaseEnumEntity): Host name is not the same as application name. Host name defines implementation instead of application name. """ - schema_types = ["hosts-enum"] all_host_names = [ "aftereffects", @@ -167,7 +169,7 @@ class HostsEnumEntity(BaseEnumEntity): "tvpaint", "unreal", "standalonepublisher", - "webpublisher", + "webpublisher" ] def _item_initialization(self): @@ -208,7 +210,7 @@ class HostsEnumEntity(BaseEnumEntity): self.valid_keys = valid_keys if self.multiselection: - self.valid_value_types = (list,) + self.valid_value_types = (list, ) self.value_on_not_set = [] else: for key in valid_keys: @@ -216,7 +218,7 @@ class HostsEnumEntity(BaseEnumEntity): self.value_on_not_set = key break - self.valid_value_types = (STRING_TYPE,) + self.valid_value_types = (STRING_TYPE, ) # GUI attribute self.placeholder = self.schema_data.get("placeholder") @@ -224,10 +226,14 @@ class HostsEnumEntity(BaseEnumEntity): def schema_validations(self): if self.hosts_filter: enum_len = len(self.enum_items) - if enum_len == 0 or (enum_len == 1 and self.use_empty_value): - joined_filters = ", ".join( - ['"{}"'.format(item) for item in self.hosts_filter] - ) + if ( + enum_len == 0 + or (enum_len == 1 and self.use_empty_value) + ): + joined_filters = ", ".join([ + '"{}"'.format(item) + for item in self.hosts_filter + ]) reason = ( "All host names were removed after applying" " host filters. {}" @@ -240,25 +246,24 @@ class HostsEnumEntity(BaseEnumEntity): invalid_filters.add(item) if invalid_filters: - joined_filters = ", ".join( - ['"{}"'.format(item) for item in self.hosts_filter] - ) - expected_hosts = ", ".join( - ['"{}"'.format(item) for item in self.all_host_names] - ) - self.log.warning( - ( - "Host filters containt invalid host names:" - ' "{}" Expected values are {}' - ).format(joined_filters, expected_hosts) - ) + joined_filters = ", ".join([ + '"{}"'.format(item) + for item in self.hosts_filter + ]) + expected_hosts = ", ".join([ + '"{}"'.format(item) + for item in self.all_host_names + ]) + self.log.warning(( + "Host filters containt invalid host names:" + " \"{}\" Expected values are {}" + ).format(joined_filters, expected_hosts)) super(HostsEnumEntity, self).schema_validations() class AppsEnumEntity(BaseEnumEntity): """Enum of applications for project anatomy attributes.""" - schema_types = ["apps-enum"] def _item_initialization(self): @@ -266,7 +271,7 @@ class AppsEnumEntity(BaseEnumEntity): self.value_on_not_set = [] self.enum_items = [] self.valid_keys = set() - self.valid_value_types = (list,) + self.valid_value_types = (list, ) self.placeholder = None def _get_enum_values(self): @@ -347,7 +352,7 @@ class ToolsEnumEntity(BaseEnumEntity): self.value_on_not_set = [] self.enum_items = [] self.valid_keys = set() - self.valid_value_types = (list,) + self.valid_value_types = (list, ) self.placeholder = None def _get_enum_values(self): @@ -404,10 +409,10 @@ class TaskTypeEnumEntity(BaseEnumEntity): def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) if self.multiselection: - self.valid_value_types = (list,) + self.valid_value_types = (list, ) self.value_on_not_set = [] else: - self.valid_value_types = (STRING_TYPE,) + self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" self.enum_items = [] @@ -502,8 +507,7 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): enum_items_list = [] for server_name, url_entity in deadline_urls_entity.items(): enum_items_list.append( - {server_name: "{}: {}".format(server_name, url_entity.value)} - ) + {server_name: "{}: {}".format(server_name, url_entity.value)}) valid_keys.add(server_name) return enum_items_list, valid_keys @@ -526,50 +530,6 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): self._current_value = tuple(self.valid_keys)[0] -class ShotgridUrlEnumEntity(BaseEnumEntity): - schema_types = ["shotgrid_url-enum"] - - def _item_initialization(self): - self.multiselection = False - - self.enum_items = [] - self.valid_keys = set() - - self.valid_value_types = (STRING_TYPE,) - self.value_on_not_set = "" - - # GUI attribute - self.placeholder = self.schema_data.get("placeholder") - - def _get_enum_values(self): - shotgrid_settings = self.get_entity_from_path( - "system_settings/modules/shotgrid/shotgrid_settings" - ) - - valid_keys = set() - enum_items_list = [] - for server_name, settings in shotgrid_settings.items(): - enum_items_list.append( - { - server_name: "{}: {}".format( - server_name, settings["shotgrid_url"].value - ) - } - ) - valid_keys.add(server_name) - return enum_items_list, valid_keys - - def set_override_state(self, *args, **kwargs): - super(ShotgridUrlEnumEntity, self).set_override_state(*args, **kwargs) - - self.enum_items, self.valid_keys = self._get_enum_values() - if not self.valid_keys: - self._current_value = "" - - elif self._current_value not in self.valid_keys: - self._current_value = tuple(self.valid_keys)[0] - - class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 80b1baad1b..6c07209de3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -62,10 +62,6 @@ "type": "schema", "name": "schema_project_ftrack" }, - { - "type": "schema", - "name": "schema_project_shotgrid" - }, { "type": "schema", "name": "schema_project_kitsu" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json b/openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json deleted file mode 100644 index 4faeca89f3..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "type": "dict", - "key": "shotgrid", - "label": "Shotgrid", - "collapsible": true, - "is_file": true, - "children": [ - { - "type": "number", - "key": "shotgrid_project_id", - "label": "Shotgrid project id" - }, - { - "type": "shotgrid_url-enum", - "key": "shotgrid_server", - "label": "Shotgrid Server" - }, - { - "type": "dict", - "key": "event", - "label": "Event Handler", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - } - ] - }, - { - "type": "dict", - "key": "fields", - "label": "Fields Template", - "collapsible": true, - "children": [ - { - "type": "dict", - "key": "asset", - "label": "Asset", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "type", - "label": "Asset Type" - } - ] - }, - { - "type": "dict", - "key": "sequence", - "label": "Sequence", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "episode_link", - "label": "Episode link" - } - ] - }, - { - "type": "dict", - "key": "shot", - "label": "Shot", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "episode_link", - "label": "Episode link" - }, - { - "type": "text", - "key": "sequence_link", - "label": "Sequence link" - } - ] - }, - { - "type": "dict", - "key": "task", - "label": "Task", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "step", - "label": "Step link" - } - ] - } - ] - } - ] -} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json index a4b28f47bc..484fbf9d07 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json @@ -13,9 +13,6 @@ { "ftrackreview": "Add review to Ftrack" }, - { - "shotgridreview": "Add review to Shotgrid" - }, { "delete": "Delete output" }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 952b38040c..d22b9016a7 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -48,60 +48,6 @@ "type": "schema", "name": "schema_kitsu" }, - { - "type": "dict", - "key": "shotgrid", - "label": "Shotgrid", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "text", - "key": "leecher_manager_url", - "label": "Shotgrid Leecher Manager URL" - }, - { - "type": "text", - "key": "leecher_backend_url", - "label": "Shotgrid Leecher Backend URL" - }, - { - "type": "boolean", - "key": "filter_projects_by_login", - "label": "Filter projects by SG login" - }, - { - "type": "dict-modifiable", - "key": "shotgrid_settings", - "label": "Shotgrid Servers", - "object_type": { - "type": "dict", - "children": [ - { - "key": "shotgrid_url", - "label": "Server URL", - "type": "text" - }, - { - "key": "shotgrid_script_name", - "label": "Script Name", - "type": "text" - }, - { - "key": "shotgrid_script_key", - "label": "Script api key", - "type": "text" - } - ] - } - } - ] - }, { "type": "dict", "key": "timers_manager", diff --git a/poetry.lock b/poetry.lock index f6ccf1ffc9..47509f334e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1362,21 +1362,6 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -[[package]] -name = "shotgun-api3" -version = "3.3.3" -description = "Shotgun Python API" -category = "main" -optional = false -python-versions = "*" -develop = false - -[package.source] -type = "git" -url = "https://github.com/shotgunsoftware/python-api.git" -reference = "v3.3.3" -resolved_reference = "b9f066c0edbea6e0733242e18f32f75489064840" - [[package]] name = "six" version = "1.16.0" @@ -2818,7 +2803,6 @@ semver = [ {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, ] -shotgun-api3 = [] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index c68de91623..4b297fe042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ clique = "1.6.*" Click = "^7" dnspython = "^2.1.0" ftrack-python-api = "2.0.*" -shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"} gazu = "^0.8.28" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) jsonschema = "^2.6.0"