From e25fcade98ac6c787f99e50214b6b52099abd80f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 2 Dec 2021 19:02:18 +0100 Subject: [PATCH 001/151] move version detection --- igniter/bootstrap_repos.py | 210 +++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 151597e505..b0f3b482ac 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -232,6 +232,216 @@ class OpenPypeVersion(semver.VersionInfo): else: return hash(str(self)) + @staticmethod + def is_version_in_dir( + dir_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: + """Test if path item is OpenPype version matching detected version. + + If item is directory that might (based on it's name) + contain OpenPype version, check if it really does contain + OpenPype and that their versions matches. + + Args: + dir_item (Path): Directory to test. + version (OpenPypeVersion): OpenPype version detected + from name. + + Returns: + Tuple: State and reason, True if it is valid OpenPype version, + False otherwise. + + """ + try: + # add one 'openpype' level as inside dir there should + # be many other repositories. + version_str = OpenPypeVersion.get_version_string_from_directory( + dir_item) # noqa: E501 + version_check = OpenPypeVersion(version=version_str) + except ValueError: + return False, f"cannot determine version from {dir_item}" + + version_main = version_check.get_main_version() + detected_main = version.get_main_version() + if version_main != detected_main: + return False, (f"dir version ({version}) and " + f"its content version ({version_check}) " + "doesn't match. Skipping.") + return True, "Versions match" + + @staticmethod + def is_version_in_zip( + zip_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: + """Test if zip path is OpenPype version matching detected version. + + Open zip file, look inside and parse version from OpenPype + inside it. If there is none, or it is different from + version specified in file name, skip it. + + Args: + zip_item (Path): Zip file to test. + version (OpenPypeVersion): Pype version detected + from name. + + Returns: + Tuple: State and reason, True if it is valid OpenPype version, + False otherwise. + + """ + # skip non-zip files + if zip_item.suffix.lower() != ".zip": + return False, "Not a zip" + + try: + with ZipFile(zip_item, "r") as zip_file: + with zip_file.open( + "openpype/version.py") as version_file: + zip_version = {} + exec(version_file.read(), zip_version) + try: + version_check = OpenPypeVersion( + version=zip_version["__version__"]) + except ValueError as e: + return False, str(e) + + version_main = version_check.get_main_version() # + # noqa: E501 + detected_main = version.get_main_version() + # noqa: E501 + + if version_main != detected_main: + return False, (f"zip version ({version}) " + f"and its content version " + f"({version_check}) " + "doesn't match. Skipping.") + except BadZipFile: + return False, f"{zip_item} is not a zip file" + except KeyError: + return False, "Zip does not contain OpenPype" + return True, "Versions match" + + @staticmethod + def get_version_string_from_directory(repo_dir: Path) -> Union[str, None]: + """Get version of OpenPype in given directory. + + Note: in frozen OpenPype installed in user data dir, this must point + one level deeper as it is: + `openpype-version-v3.0.0/openpype/version.py` + + Args: + repo_dir (Path): Path to OpenPype repo. + + Returns: + str: version string. + None: if OpenPype is not found. + + """ + # try to find version + version_file = Path(repo_dir) / "openpype" / "version.py" + if not version_file.exists(): + return None + + version = {} + with version_file.open("r") as fp: + exec(fp.read(), version) + + return version['__version__'] + + @staticmethod + def get_available_versions( + staging: bool = False, local: bool = False) -> List: + """Get ordered dict of detected OpenPype version. + + Resolution order for OpenPype is following: + + 1) First we test for ``OPENPYPE_PATH`` environment variable + 2) We try to find ``openPypePath`` in registry setting + 3) We use user data directory + + Only versions from 3) will be listed when ``local`` is set to True. + + Args: + staging (bool, optional): List staging versions if True. + local (bool, optional): List only local versions. + + """ + registry = OpenPypeSettingsRegistry() + dir_to_search = Path(user_data_dir("openpype", "pypeclub")) + user_versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search) + # if we have openpype_path specified, search only there. + + if not local: + if os.getenv("OPENPYPE_PATH"): + if Path(os.getenv("OPENPYPE_PATH")).exists(): + dir_to_search = Path(os.getenv("OPENPYPE_PATH")) + else: + try: + registry_dir = Path( + str(registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + openpype_versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search) + openpype_versions += user_versions + + # remove duplicates and staging/production + openpype_versions = [ + v for v in openpype_versions if v.is_staging() == staging + ] + openpype_versions = sorted(list(set(openpype_versions))) + + return openpype_versions + + @staticmethod + def get_versions_from_directory(openpype_dir: Path) -> List: + """Get all detected OpenPype versions in directory. + + Args: + openpype_dir (Path): Directory to scan. + + Returns: + list of OpenPypeVersion + + Throws: + ValueError: if invalid path is specified. + + """ + if not openpype_dir.exists() and not openpype_dir.is_dir(): + raise ValueError("specified directory is invalid") + + _openpype_versions = [] + # iterate over directory in first level and find all that might + # contain OpenPype. + for item in openpype_dir.iterdir(): + + # if file, strip extension, in case of dir not. + name = item.name if item.is_dir() else item.stem + result = OpenPypeVersion.version_in_str(name) + + if result: + detected_version: OpenPypeVersion + detected_version = result + + if item.is_dir() and not OpenPypeVersion.is_version_in_dir( + item, detected_version + )[0]: + continue + + if item.is_file() and not OpenPypeVersion.is_version_in_zip( + item, detected_version + )[0]: + continue + + detected_version.path = item + _openpype_versions.append(detected_version) + + return sorted(_openpype_versions) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. From 383307d88803404c1bac74366797b5a23e2fc5e0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 3 Dec 2021 13:51:03 +0100 Subject: [PATCH 002/151] tweak tests and add get latest version --- igniter/bootstrap_repos.py | 97 +++++++++------------- start.py | 1 - tests/unit/igniter/test_bootstrap_repos.py | 7 +- 3 files changed, 41 insertions(+), 64 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b0f3b482ac..ca64d193c7 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -401,16 +401,16 @@ class OpenPypeVersion(semver.VersionInfo): def get_versions_from_directory(openpype_dir: Path) -> List: """Get all detected OpenPype versions in directory. - Args: - openpype_dir (Path): Directory to scan. + Args: + openpype_dir (Path): Directory to scan. - Returns: - list of OpenPypeVersion + Returns: + list of OpenPypeVersion - Throws: - ValueError: if invalid path is specified. + Throws: + ValueError: if invalid path is specified. - """ + """ if not openpype_dir.exists() and not openpype_dir.is_dir(): raise ValueError("specified directory is invalid") @@ -442,6 +442,26 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) + @staticmethod + def get_latest_version( + staging: bool = False, local: bool = False) -> OpenPypeVersion: + """Get latest available version. + + This is utility version to get latest version from all found. + + Args: + staging (bool, optional): List staging versions if True. + local (bool, optional): List only local versions. + + See also: + OpenPypeVersion.get_available_versions() + + """ + openpype_versions = OpenPypeVersion.get_available_versions( + staging, local) + + return openpype_versions[-1] + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. @@ -944,66 +964,23 @@ class BootstrapRepos: os.environ["PYTHONPATH"] = os.pathsep.join(paths) + @staticmethod def find_openpype( - self, openpype_path: Union[Path, str] = None, staging: bool = False, include_zips: bool = False) -> Union[List[OpenPypeVersion], None]: - """Get ordered dict of detected OpenPype version. - Resolution order for OpenPype is following: - - 1) First we test for ``OPENPYPE_PATH`` environment variable - 2) We try to find ``openPypePath`` in registry setting - 3) We use user data directory - - Args: - openpype_path (Path or str, optional): Try to find OpenPype on - the given path or url. - staging (bool, optional): Filter only staging version, skip them - otherwise. - include_zips (bool, optional): If set True it will try to find - OpenPype in zip files in given directory. - - Returns: - dict of Path: Dictionary of detected OpenPype version. - Key is version, value is path to zip file. - - None: if OpenPype is not found. - - Todo: - implement git/url support as OpenPype location, so it would be - possible to enter git url, OpenPype would check it out and if it is - ok install it as normal version. - - """ - if openpype_path and not isinstance(openpype_path, Path): - raise NotImplementedError( - ("Finding OpenPype in non-filesystem locations is" - " not implemented yet.")) - - dir_to_search = self.data_dir - user_versions = self.get_openpype_versions(self.data_dir, staging) - # if we have openpype_path specified, search only there. if openpype_path: - dir_to_search = openpype_path + openpype_versions = OpenPypeVersion.get_versions_from_directory( + openpype_path) + # filter out staging + + openpype_versions = [ + v for v in openpype_versions if v.is_staging() == staging + ] + else: - if os.getenv("OPENPYPE_PATH"): - if Path(os.getenv("OPENPYPE_PATH")).exists(): - dir_to_search = Path(os.getenv("OPENPYPE_PATH")) - else: - try: - registry_dir = Path( - str(self.registry.get_item("openPypePath"))) - if registry_dir.exists(): - dir_to_search = registry_dir - - except ValueError: - # nothing found in registry, we'll use data dir - pass - - openpype_versions = self.get_openpype_versions(dir_to_search, staging) - openpype_versions += user_versions + openpype_versions = OpenPypeVersion.get_available_versions(staging) # remove zip file version if needed. if not include_zips: diff --git a/start.py b/start.py index 0f7e82071d..05b7da6308 100644 --- a/start.py +++ b/start.py @@ -966,7 +966,6 @@ def boot(): ) sys.exit(1) - if not openpype_path: _print("*** Cannot get OpenPype path from database.") diff --git a/tests/unit/igniter/test_bootstrap_repos.py b/tests/unit/igniter/test_bootstrap_repos.py index d6e861c262..65cd5a2399 100644 --- a/tests/unit/igniter/test_bootstrap_repos.py +++ b/tests/unit/igniter/test_bootstrap_repos.py @@ -140,9 +140,10 @@ def test_search_string_for_openpype_version(printer): ] for ver_string in strings: printer(f"testing {ver_string[0]} should be {ver_string[1]}") - assert OpenPypeVersion.version_in_str(ver_string[0]) == \ - ver_string[1] - + assert isinstance( + OpenPypeVersion.version_in_str(ver_string[0]), + OpenPypeVersion if ver_string[1] else type(None) + ) @pytest.mark.slow def test_install_live_repos(fix_bootstrap, printer, monkeypatch, pytestconfig): From 88218caf2869d8452b734ed4712fa224a540b0c6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 15:56:11 +0100 Subject: [PATCH 003/151] store OpenPypeVersion to sys.modules --- igniter/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index defd45e233..bbc3dbfc88 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -6,9 +6,15 @@ import sys os.chdir(os.path.dirname(__file__)) # for override sys.path in Deadline -from .bootstrap_repos import BootstrapRepos +from .bootstrap_repos import ( + BootstrapRepos, + OpenPypeVersion +) from .version import __version__ as version +if "OpenPypeVersion" not in sys.modules: + sys.modules["OpenPypeVersion"] = OpenPypeVersion + def open_dialog(): """Show Igniter dialog.""" From 2bfdc5c3e970b1747e39b3ed16f439fe4d0e59d2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 16:05:36 +0100 Subject: [PATCH 004/151] create new enum entities used to determine openpype version --- openpype/settings/entities/__init__.py | 6 ++- openpype/settings/entities/enum_entity.py | 52 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index ccf2a5993e..e4a13b8053 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -106,7 +106,9 @@ from .enum_entity import ( ToolsEnumEntity, TaskTypeEnumEntity, DeadlineUrlEnumEntity, - AnatomyTemplatesEnumEntity + AnatomyTemplatesEnumEntity, + ProductionVersionsEnumEntity, + StagingVersionsEnumEntity ) from .list_entity import ListEntity @@ -169,6 +171,8 @@ __all__ = ( "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", "AnatomyTemplatesEnumEntity", + "ProductionVersionsEnumEntity", + "StagingVersionsEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index ab3cebbd42..5f0cbb1261 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,4 +1,5 @@ import copy +import sys from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( @@ -564,3 +565,54 @@ class AnatomyTemplatesEnumEntity(BaseEnumEntity): self.enum_items, self.valid_keys = self._get_enum_values() if self._current_value not in self.valid_keys: self._current_value = self.value_on_not_set + + +class _OpenPypeVersionEnum(BaseEnumEntity): + def _item_initialization(self): + self.multiselection = False + self.valid_value_types = (STRING_TYPE, ) + + self.value_on_not_set = "" + items = [ + {"": "Latest"} + ] + items.extend(self._get_openpype_versions()) + + self.enum_items = items + self.valid_keys = { + tuple(item.keys())[0] + for item in items + } + + def _get_openpype_versions(self): + return [] + + +class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): + schema_types = ["production-versions-enum"] + + def _get_openpype_versions(self): + items = [] + if "OpenPypeVersion" in sys.modules: + OpenPypeVersion = sys.modules["OpenPypeVersion"] + versions = OpenPypeVersion.get_available_versions( + staging=False, local=False + ) + for item in versions: + items.append({item: item}) + return items + + +class StagingVersionsEnumEntity(_OpenPypeVersionEnum): + schema_types = ["staging-versions-enum"] + + def _get_openpype_versions(self): + items = [] + if "OpenPypeVersion" in sys.modules: + OpenPypeVersion = sys.modules["OpenPypeVersion"] + versions = OpenPypeVersion.get_available_versions( + staging=False, local=False + ) + for item in versions: + items.append({item: item}) + return items From 27b1be9279152b7fde4c3fbd5bb9d2d507cfc299 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 16:05:49 +0100 Subject: [PATCH 005/151] use new enities in settings --- .../settings/defaults/system_settings/general.json | 2 ++ .../schemas/system_schema/schema_general.json | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index f54e8b2b16..a07152eaf8 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -2,6 +2,8 @@ "studio_name": "Studio name", "studio_code": "stu", "admin_password": "", + "production_version": "", + "staging_version": "", "environment": { "__environment_keys__": { "global": [] diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 51a58a6e27..d548a22c97 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -30,6 +30,19 @@ { "type": "splitter" }, + { + "type": "production-versions-enum", + "key": "production_version", + "label": "Production version" + }, + { + "type": "staging-versions-enum", + "key": "staging_version", + "label": "Staging version" + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", From ab8dacd0f8cc4e7f578695a6a5159472876ea1aa Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 16:06:04 +0100 Subject: [PATCH 006/151] added "production_version" and "staging_version" to global settings --- openpype/settings/handlers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index c59e2bc542..51e390bb6d 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -168,7 +168,13 @@ class CacheValues: class MongoSettingsHandler(SettingsHandler): """Settings handler that use mongo for storing and loading of settings.""" - global_general_keys = ("openpype_path", "admin_password", "disk_mapping") + global_general_keys = ( + "openpype_path", + "admin_password", + "disk_mapping", + "production_version", + "staging_version" + ) def __init__(self): # Get mongo connection From 80d887f14e80a1c2862343f1407280c1341a44ec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 17:44:49 +0100 Subject: [PATCH 007/151] added 3 method related to openpype path --- igniter/bootstrap_repos.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index ca64d193c7..921557b1a0 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -346,6 +346,37 @@ class OpenPypeVersion(semver.VersionInfo): return version['__version__'] + @classmethod + def get_openpype_path(cls): + """Path to openpype zip directory. + + Path can be set through environment variable 'OPENPYPE_PATH' which + is set during start of OpenPype if is not available. + """ + return os.getenv("OPENPYPE_PATH") + + @classmethod + def openpype_path_is_set(cls): + """Path to OpenPype zip directory is set.""" + if cls.get_openpype_path(): + return True + return False + + @classmethod + def openpype_path_is_accessible(cls): + """Path to OpenPype zip directory is accessible. + + Exists for this machine. + """ + # First check if is set + if not cls.openpype_path_is_set(): + return False + + # Validate existence + if Path(cls.get_openpype_path()).exists(): + return True + return False + @staticmethod def get_available_versions( staging: bool = False, local: bool = False) -> List: From 494bd26d80e41e0169344442ae2b773642e4191f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:04:18 +0100 Subject: [PATCH 008/151] created OpenPypeVersion wrapper in openpype lib --- openpype/lib/openpype_version.py | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 openpype/lib/openpype_version.py diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py new file mode 100644 index 0000000000..4d1a1a7bb8 --- /dev/null +++ b/openpype/lib/openpype_version.py @@ -0,0 +1,59 @@ +"""Lib access to OpenPypeVersion from igniter. + +Access to logic from igniter is available only for OpenPype processes. +Is meant to be able check OpenPype versions for studio. The logic is dependent +on igniter's logic of processing. +""" + +import sys + + +def get_OpenPypeVersion(): + """Access to OpenPypeVersion class stored in sys modules.""" + return sys.modules.get("OpenPypeVersion") + + +def op_version_control_available(): + """Check if current process has access to OpenPypeVersion.""" + if get_OpenPypeVersion() is None: + return False + return True + + +def get_available_versions(*args, **kwargs): + """Get list of available versions.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_available_versions( + *args, **kwargs + ) + return None + + +def openpype_path_is_set(): + if op_version_control_available(): + return get_OpenPypeVersion().openpype_path_is_set() + return None + + +def openpype_path_is_accessible(): + if op_version_control_available(): + return get_OpenPypeVersion().openpype_path_is_accessible() + return None + + +def get_latest_version(): + if op_version_control_available(): + return get_OpenPypeVersion().get_latest_version() + return None + + +def get_production_version(): + if op_version_control_available(): + return get_OpenPypeVersion().get_production_version() + return None + + +def get_staging_version(): + if op_version_control_available(): + return get_OpenPypeVersion().get_staging_version() + return None From cc1cc49235f288ba95d8d213e5fe16a8c67d397f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:06:52 +0100 Subject: [PATCH 009/151] added logic to load data for version entities --- openpype/settings/entities/enum_entity.py | 75 ++++++++++++++++++----- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 5f0cbb1261..591b2dd94f 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,8 +1,15 @@ import copy import sys +from openpype.lib.openpype_version import ( + op_version_control_available, + get_available_versions, + openpype_path_is_set, + openpype_path_is_accessible +) from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( + OverrideState, NOT_SET, STRING_TYPE ) @@ -573,46 +580,84 @@ class _OpenPypeVersionEnum(BaseEnumEntity): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" - items = [ - {"": "Latest"} - ] - items.extend(self._get_openpype_versions()) + items = self._get_default_items() self.enum_items = items - self.valid_keys = { + self.valid_keys = self._extract_valid_keys(items) + + def _extract_valid_keys(self, items): + return { tuple(item.keys())[0] for item in items } + def _get_default_items(self): + return [ + {"": "Latest"} + ] + def _get_openpype_versions(self): return [] + def set_override_state(self, state, *args, **kwargs): + items = self._get_default_items() + versions = self._get_openpype_versions() + if versions is not None: + for version in versions: + items.append({version: version}) + + self.enum_items = items + self.valid_keys = self._extract_valid_keys(items) + + # Studio value is not available in collected versions + if ( + state is OverrideState.STUDIO + and self.had_studio_override + and self._studio_override_value not in self.valid_keys + ): + # Define if entity should keep the value in settings. + # Value is marked as not existing anymore if + # - openpype version control is available + # - path to openpype zips is set + # - path to openpype zips is accessible (existing for this machine) + keep_value = True + if ( + op_version_control_available() + and openpype_path_is_set() + and openpype_path_is_accessible() + ): + keep_value = False + + if keep_value: + self.enum_items.append( + {self._studio_override_value: self._studio_override_value} + ) + self.valid_keys.add(self._studio_override_value) + + super(_OpenPypeVersionEnum, self).set_override_state( + state, *args, **kwargs + ) + class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): schema_types = ["production-versions-enum"] def _get_openpype_versions(self): - items = [] if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - versions = OpenPypeVersion.get_available_versions( + return get_available_versions( staging=False, local=False ) - for item in versions: - items.append({item: item}) - return items + return None class StagingVersionsEnumEntity(_OpenPypeVersionEnum): schema_types = ["staging-versions-enum"] def _get_openpype_versions(self): - items = [] if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - versions = OpenPypeVersion.get_available_versions( + return get_available_versions( staging=False, local=False ) - for item in versions: - items.append({item: item}) - return items + return None From e9b0347d8309705da17a57463f59a95bb8d1a042 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:26:30 +0100 Subject: [PATCH 010/151] changed static method to class method --- igniter/bootstrap_repos.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 921557b1a0..5cade8324c 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -377,9 +377,9 @@ class OpenPypeVersion(semver.VersionInfo): return True return False - @staticmethod + @classmethod def get_available_versions( - staging: bool = False, local: bool = False) -> List: + cls, staging: bool = False, local: bool = False) -> List: """Get ordered dict of detected OpenPype version. Resolution order for OpenPype is following: From 6762d6645957dc177796a067c64856fafe6096b2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:26:54 +0100 Subject: [PATCH 011/151] added functions to retrieve local and remote versions separatelly --- igniter/bootstrap_repos.py | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 5cade8324c..c52175c8fd 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -377,6 +377,90 @@ class OpenPypeVersion(semver.VersionInfo): return True return False + @classmethod + def get_local_versions( + cls, production: bool = None, staging: bool = None + ) -> List: + """Get all versions available on this machine. + + Arguments give ability to specify if filtering is needed. If both + arguments are set to None all found versions are returned. + + Args: + production (bool): Return production versions. + staging (bool): Return staging versions. + """ + # Return all local versions if arguments are set to None + if production is None and staging is None: + production = True + staging = True + + # Just return empty output if both are disabled + elif not production and not staging: + return [] + + dir_to_search = Path(user_data_dir("openpype", "pypeclub")) + versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search + ) + filtered_versions = [] + for version in versions: + if version.is_staging(): + if staging: + filtered_versions.append(version) + elif production: + filtered_versions.append(version) + return list(sorted(set(filtered_versions))) + + @classmethod + def get_remote_versions( + cls, production: bool = None, staging: bool = None + ) -> List: + """Get all versions available in OpenPype Path. + + Arguments give ability to specify if filtering is needed. If both + arguments are set to None all found versions are returned. + + Args: + production (bool): Return production versions. + staging (bool): Return staging versions. + """ + # Return all local versions if arguments are set to None + if production is None and staging is None: + production = True + staging = True + + # Just return empty output if both are disabled + elif not production and not staging: + return [] + + dir_to_search = None + if cls.openpype_path_is_accessible(): + dir_to_search = Path(cls.get_openpype_path()) + else: + registry = OpenPypeSettingsRegistry() + try: + registry_dir = Path(str(registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + if not dir_to_search: + return [] + + versions = cls.get_versions_from_directory(dir_to_search) + filtered_versions = [] + for version in versions: + if version.is_staging(): + if staging: + filtered_versions.append(version) + elif production: + filtered_versions.append(version) + return list(sorted(set(filtered_versions))) + @classmethod def get_available_versions( cls, staging: bool = False, local: bool = False) -> List: From 73f29819873c632111a3a2b3a644afc36f04509a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:27:14 +0100 Subject: [PATCH 012/151] use new functions in get_available_versions --- igniter/bootstrap_repos.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index c52175c8fd..5bb7296d3d 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -479,29 +479,11 @@ class OpenPypeVersion(semver.VersionInfo): local (bool, optional): List only local versions. """ - registry = OpenPypeSettingsRegistry() - dir_to_search = Path(user_data_dir("openpype", "pypeclub")) - user_versions = OpenPypeVersion.get_versions_from_directory( - dir_to_search) + user_versions = cls.get_local_versions() # if we have openpype_path specified, search only there. - + openpype_versions = [] if not local: - if os.getenv("OPENPYPE_PATH"): - if Path(os.getenv("OPENPYPE_PATH")).exists(): - dir_to_search = Path(os.getenv("OPENPYPE_PATH")) - else: - try: - registry_dir = Path( - str(registry.get_item("openPypePath"))) - if registry_dir.exists(): - dir_to_search = registry_dir - - except ValueError: - # nothing found in registry, we'll use data dir - pass - - openpype_versions = OpenPypeVersion.get_versions_from_directory( - dir_to_search) + openpype_versions = cls.get_remote_versions() openpype_versions += user_versions # remove duplicates and staging/production From a14662bccee58dd50faeb887842889ae7dc63ff1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:35:21 +0100 Subject: [PATCH 013/151] fix args conditions --- igniter/bootstrap_repos.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 5bb7296d3d..94f786e869 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -395,8 +395,14 @@ class OpenPypeVersion(semver.VersionInfo): production = True staging = True + elif production is None and not staging: + production = True + + elif staging is None and not production: + staging = True + # Just return empty output if both are disabled - elif not production and not staging: + if not production and not staging: return [] dir_to_search = Path(user_data_dir("openpype", "pypeclub")) @@ -430,8 +436,14 @@ class OpenPypeVersion(semver.VersionInfo): production = True staging = True + elif production is None and not staging: + production = True + + elif staging is None and not production: + staging = True + # Just return empty output if both are disabled - elif not production and not staging: + if not production and not staging: return [] dir_to_search = None From 1567afc4b9ba205669f3e716a96efb8963c5e79c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:35:34 +0100 Subject: [PATCH 014/151] extended available functions --- openpype/lib/openpype_version.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 4d1a1a7bb8..42ee454378 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -41,19 +41,31 @@ def openpype_path_is_accessible(): return None -def get_latest_version(): +def get_local_versions(*args, **kwargs): if op_version_control_available(): - return get_OpenPypeVersion().get_latest_version() + return get_OpenPypeVersion().get_local_versions(*args, **kwargs) return None -def get_production_version(): +def get_remote_versions(*args, **kwargs): + if op_version_control_available(): + return get_OpenPypeVersion().get_remote_versions(*args, **kwargs) + return None + + +def get_latest_version(*args, **kwargs): + if op_version_control_available(): + return get_OpenPypeVersion().get_latest_version(*args, **kwargs) + return None + + +def get_current_production_version(): if op_version_control_available(): return get_OpenPypeVersion().get_production_version() return None -def get_staging_version(): +def get_current_staging_version(): if op_version_control_available(): return get_OpenPypeVersion().get_staging_version() return None From 7aec21aa800dc225fbc5d2db9f46e8defcd26f29 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:35:45 +0100 Subject: [PATCH 015/151] use extended functions in entities --- openpype/settings/entities/enum_entity.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 591b2dd94f..534775a41d 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -2,7 +2,7 @@ import copy import sys from openpype.lib.openpype_version import ( op_version_control_available, - get_available_versions, + get_remote_versions, openpype_path_is_set, openpype_path_is_accessible ) @@ -645,9 +645,7 @@ class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): def _get_openpype_versions(self): if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_available_versions( - staging=False, local=False - ) + return get_remote_versions(production=True) return None @@ -657,7 +655,5 @@ class StagingVersionsEnumEntity(_OpenPypeVersionEnum): def _get_openpype_versions(self): if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_available_versions( - staging=False, local=False - ) + return get_remote_versions(staging=True) return None From 8f0f0769800645e4966fc1e301ce3c1e97bde925 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 11:51:35 +0100 Subject: [PATCH 016/151] added functions to get expected studio version --- igniter/bootstrap_repos.py | 23 ++++++++++++++++++++++- igniter/tools.py | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 94f786e869..9446b3e8ce 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -22,7 +22,10 @@ from .user_settings import ( OpenPypeSecureRegistry, OpenPypeSettingsRegistry ) -from .tools import get_openpype_path_from_db +from .tools import ( + get_openpype_path_from_db, + get_expected_studio_version_str +) LOG_INFO = 0 @@ -571,6 +574,24 @@ class OpenPypeVersion(semver.VersionInfo): return openpype_versions[-1] + @classmethod + def get_expected_studio_version(cls, staging=False): + """Expected OpenPype version that should be used at the moment. + + If version is not defined in settings the latest found version is + used. + + Args: + staging (bool): Staging version or production version. + + Returns: + OpenPypeVersion: Version that should be used. + """ + result = get_expected_studio_version_str(staging) + if not result: + return cls.get_latest_version(staging, False) + return OpenPypeVersion(version=result) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. diff --git a/igniter/tools.py b/igniter/tools.py index 3e862f5803..5cad2b9bf8 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -182,6 +182,24 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: return None +def get_expected_studio_version_str(staging=False) -> str: + """Version that should be currently used in studio. + + Args: + staging (bool): Get current version for staging. + + Returns: + str: OpenPype version which should be used. Empty string means latest. + """ + mongo_url = os.environ.get("OPENPYPE_MONGO") + global_settings = get_openpype_global_settings(mongo_url) + if staging: + key = "staging_version" + else: + key = "production_version" + return global_settings.get(key) or "" + + def load_stylesheet() -> str: """Load css style sheet. From 12498862943b12ba7c039ba19b71d58fa7e85de5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:10:29 +0100 Subject: [PATCH 017/151] text entity may have value hints --- openpype/settings/entities/input_entities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index a0598d405e..d45e8f9f01 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -438,6 +438,7 @@ class TextEntity(InputEntity): # GUI attributes self.multiline = self.schema_data.get("multiline", False) self.placeholder_text = self.schema_data.get("placeholder") + self.value_hints = self.schema_data.get("value_hints") or [] def _convert_to_valid_type(self, value): # Allow numbers converted to string From 376c4f977864a285b2ca2f271e1cf9f4e5571589 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:10:49 +0100 Subject: [PATCH 018/151] changed openpype version enums to text inputs --- openpype/settings/entities/__init__.py | 14 +-- openpype/settings/entities/enum_entity.py | 87 ------------------- .../settings/entities/op_version_entity.py | 49 +++++++++++ .../schemas/system_schema/schema_general.json | 4 +- 4 files changed, 59 insertions(+), 95 deletions(-) create mode 100644 openpype/settings/entities/op_version_entity.py diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index e4a13b8053..4efd358297 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -107,8 +107,6 @@ from .enum_entity import ( TaskTypeEnumEntity, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity, - ProductionVersionsEnumEntity, - StagingVersionsEnumEntity ) from .list_entity import ListEntity @@ -124,7 +122,10 @@ from .dict_conditional import ( ) from .anatomy_entities import AnatomyEntity - +from .op_version_entity import ( + ProductionVersionsInputEntity, + StagingVersionsInputEntity +) __all__ = ( "DefaultsNotDefined", @@ -171,8 +172,6 @@ __all__ = ( "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", "AnatomyTemplatesEnumEntity", - "ProductionVersionsEnumEntity", - "StagingVersionsEnumEntity", "ListEntity", @@ -185,5 +184,8 @@ __all__ = ( "DictConditionalEntity", "SyncServerProviders", - "AnatomyEntity" + "AnatomyEntity", + + "ProductionVersionsInputEntity", + "StagingVersionsInputEntity" ) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 534775a41d..1f9b361f16 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,5 +1,4 @@ import copy -import sys from openpype.lib.openpype_version import ( op_version_control_available, get_remote_versions, @@ -9,7 +8,6 @@ from openpype.lib.openpype_version import ( from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( - OverrideState, NOT_SET, STRING_TYPE ) @@ -572,88 +570,3 @@ class AnatomyTemplatesEnumEntity(BaseEnumEntity): self.enum_items, self.valid_keys = self._get_enum_values() if self._current_value not in self.valid_keys: self._current_value = self.value_on_not_set - - -class _OpenPypeVersionEnum(BaseEnumEntity): - def _item_initialization(self): - self.multiselection = False - self.valid_value_types = (STRING_TYPE, ) - - self.value_on_not_set = "" - items = self._get_default_items() - - self.enum_items = items - self.valid_keys = self._extract_valid_keys(items) - - def _extract_valid_keys(self, items): - return { - tuple(item.keys())[0] - for item in items - } - - def _get_default_items(self): - return [ - {"": "Latest"} - ] - - def _get_openpype_versions(self): - return [] - - def set_override_state(self, state, *args, **kwargs): - items = self._get_default_items() - versions = self._get_openpype_versions() - if versions is not None: - for version in versions: - items.append({version: version}) - - self.enum_items = items - self.valid_keys = self._extract_valid_keys(items) - - # Studio value is not available in collected versions - if ( - state is OverrideState.STUDIO - and self.had_studio_override - and self._studio_override_value not in self.valid_keys - ): - # Define if entity should keep the value in settings. - # Value is marked as not existing anymore if - # - openpype version control is available - # - path to openpype zips is set - # - path to openpype zips is accessible (existing for this machine) - keep_value = True - if ( - op_version_control_available() - and openpype_path_is_set() - and openpype_path_is_accessible() - ): - keep_value = False - - if keep_value: - self.enum_items.append( - {self._studio_override_value: self._studio_override_value} - ) - self.valid_keys.add(self._studio_override_value) - - super(_OpenPypeVersionEnum, self).set_override_state( - state, *args, **kwargs - ) - - -class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): - schema_types = ["production-versions-enum"] - - def _get_openpype_versions(self): - if "OpenPypeVersion" in sys.modules: - OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_remote_versions(production=True) - return None - - -class StagingVersionsEnumEntity(_OpenPypeVersionEnum): - schema_types = ["staging-versions-enum"] - - def _get_openpype_versions(self): - if "OpenPypeVersion" in sys.modules: - OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_remote_versions(staging=True) - return None diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py new file mode 100644 index 0000000000..e00c6a4737 --- /dev/null +++ b/openpype/settings/entities/op_version_entity.py @@ -0,0 +1,49 @@ +from openpype.lib.openpype_version import ( + op_version_control_available, + get_remote_versions, + openpype_path_is_set, + openpype_path_is_accessible +) +from .input_entities import TextEntity +from .lib import OverrideState + + +class _OpenPypeVersionInput(TextEntity): + def _item_initialization(self): + super(_OpenPypeVersionInput, self)._item_initialization() + self.multiline = False + self.placeholder_text = "Latest" + self.value_hints = [] + + def _get_openpype_versions(self): + return [] + + def set_override_state(self, state, *args, **kwargs): + value_hints = [] + if state is OverrideState.STUDIO: + versions = self._get_openpype_versions() + if versions is not None: + for version in versions: + value_hints.append(str(version)) + + self.value_hints = value_hints + + super(_OpenPypeVersionInput, self).set_override_state( + state, *args, **kwargs + ) + + +class ProductionVersionsInputEntity(_OpenPypeVersionInput): + schema_types = ["production-versions-text"] + + def _get_openpype_versions(self): + return ["", "asd", "dsa", "3.6"] + return get_remote_versions(production=True) + + +class StagingVersionsInputEntity(_OpenPypeVersionInput): + schema_types = ["staging-versions-text"] + + def _get_openpype_versions(self): + return ["", "asd+staging", "dsa+staging", "3.6+staging"] + return get_remote_versions(staging=True) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index d548a22c97..b848a34dda 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -31,12 +31,12 @@ "type": "splitter" }, { - "type": "production-versions-enum", + "type": "production-versions-text", "key": "production_version", "label": "Production version" }, { - "type": "staging-versions-enum", + "type": "staging-versions-text", "key": "staging_version", "label": "Staging version" }, From d856c4602b712d6619883dfb7da819eb2d01dd84 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:11:26 +0100 Subject: [PATCH 019/151] added completer implementation for value hints --- openpype/tools/settings/settings/widgets.py | 220 ++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 7a7213fa66..bf59f605c9 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -1,4 +1,5 @@ import os +import copy from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from avalon.mongodb import ( @@ -24,13 +25,232 @@ from .constants import ( ) +class CompleterFilter(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(CompleterFilter, self).__init__(*args, **kwargs) + + self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + self._text_filter = "" + + def set_text_filter(self, text): + if self._text_filter == text: + return + self._text_filter = text + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent_index): + if not self._text_filter: + return True + model = self.sourceModel() + index = model.index(row, self.filterKeyColumn(), parent_index) + value = index.data(QtCore.Qt.DisplayRole) + if self._text_filter in value: + if self._text_filter == value: + return False + return True + return False + + +class CompleterView(QtWidgets.QListView): + row_activated = QtCore.Signal(str) + + def __init__(self, parent): + super(CompleterView, self).__init__(parent) + + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint + | QtCore.Qt.Tool + ) + delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(delegate) + + model = QtGui.QStandardItemModel() + filter_model = CompleterFilter() + filter_model.setSourceModel(model) + self.setModel(filter_model) + + # self.installEventFilter(parent) + + self.clicked.connect(self._on_activated) + + self._last_loaded_values = None + self._model = model + self._filter_model = filter_model + self._delegate = delegate + + def _on_activated(self, index): + if index.isValid(): + value = index.data(QtCore.Qt.DisplayRole) + self.row_activated.emit(value) + + def set_text_filter(self, text): + self._filter_model.set_text_filter(text) + self._update_geo() + + def sizeHint(self): + result = super(CompleterView, self).sizeHint() + height = 0 + for row in range(self._filter_model.rowCount()): + height += self.sizeHintForRow(row) + result.setHeight(height) + return result + + def _update_geo(self): + size_hint = self.sizeHint() + self.resize(size_hint.width(), size_hint.height()) + + def update_values(self, values): + if not values: + values = [] + + if self._last_loaded_values == values: + return + self._last_loaded_values = copy.deepcopy(values) + + root_item = self._model.invisibleRootItem() + existing_values = {} + for row in reversed(range(root_item.rowCount())): + child = root_item.child(row) + value = child.data(QtCore.Qt.DisplayRole) + if value not in values: + root_item.removeRows(child.row()) + else: + existing_values[value] = child + + for row, value in enumerate(values): + if value in existing_values: + item = existing_values[value] + if item.row() == row: + continue + else: + item = QtGui.QStandardItem(value) + item.setEditable(False) + + root_item.setChild(row, item) + + self._update_geo() + + def _get_selected_row(self): + indexes = self.selectionModel().selectedIndexes() + if not indexes: + return -1 + return indexes[0].row() + + def _select_row(self, row): + index = self._filter_model.index(row, 0) + self.setCurrentIndex(index) + + def move_up(self): + rows = self._filter_model.rowCount() + if rows == 0: + return + + selected_row = self._get_selected_row() + if selected_row < 0: + new_row = rows - 1 + else: + new_row = selected_row - 1 + if new_row < 0: + new_row = rows - 1 + + if new_row != selected_row: + self._select_row(new_row) + + def move_down(self): + rows = self._filter_model.rowCount() + if rows == 0: + return + + selected_row = self._get_selected_row() + if selected_row < 0: + new_row = 0 + else: + new_row = selected_row + 1 + if new_row >= rows: + new_row = 0 + + if new_row != selected_row: + self._select_row(new_row) + + def enter_pressed(self): + selected_row = self._get_selected_row() + if selected_row < 0: + return + index = self._filter_model.index(selected_row, 0) + self._on_activated(index) + + class SettingsLineEdit(QtWidgets.QLineEdit): focused_in = QtCore.Signal() + def __init__(self, *args, **kwargs): + super(SettingsLineEdit, self).__init__(*args, **kwargs) + + self._completer = None + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, text): + if self._completer is not None: + self._completer.set_text_filter(text) + + def _update_completer(self): + if self._completer is None or not self._completer.isVisible(): + return + point = self.frameGeometry().bottomLeft() + new_point = self.mapToGlobal(point) + self._completer.move(new_point) + def focusInEvent(self, event): super(SettingsLineEdit, self).focusInEvent(event) self.focused_in.emit() + if self._completer is None: + return + self._completer.show() + self._update_completer() + + def focusOutEvent(self, event): + super(SettingsLineEdit, self).focusOutEvent(event) + if self._completer is not None: + self._completer.hide() + + def paintEvent(self, event): + super(SettingsLineEdit, self).paintEvent(event) + self._update_completer() + + def update_completer_values(self, values): + if not values and self._completer is None: + return + + self._create_completer() + + self._completer.update_values(values) + + def _create_completer(self): + if self._completer is None: + self._completer = CompleterView(self) + self._completer.row_activated.connect(self._completer_activated) + + def _completer_activated(self, text): + self.setText(text) + + def keyPressEvent(self, event): + if self._completer is None: + super(SettingsLineEdit, self).keyPressEvent(event) + return + + key = event.key() + if key == QtCore.Qt.Key_Up: + self._completer.move_up() + elif key == QtCore.Qt.Key_Down: + self._completer.move_down() + elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + self._completer.enter_pressed() + else: + super(SettingsLineEdit, self).keyPressEvent(event) + class SettingsPlainTextEdit(QtWidgets.QPlainTextEdit): focused_in = QtCore.Signal() From 4c2ccb014c3c2a1c815638b5e9f90c86fd36c5cb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:12:17 +0100 Subject: [PATCH 020/151] text input fills value hints --- openpype/tools/settings/settings/item_widgets.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 2e00967a60..f20deeb6e4 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -2,6 +2,9 @@ import json from Qt import QtWidgets, QtCore, QtGui +from openpype.widgets.sliders import NiceSlider +from openpype.tools.settings import CHILD_OFFSET + from .widgets import ( ExpandingWidget, NumberSpinBox, @@ -22,9 +25,6 @@ from .base import ( InputWidget ) -from openpype.widgets.sliders import NiceSlider -from openpype.tools.settings import CHILD_OFFSET - class DictImmutableKeysWidget(BaseWidget): def create_ui(self): @@ -378,6 +378,11 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + self._refresh_completer() + + def _refresh_completer(self): + self.input_field.update_completer_values(self.entity.value_hints) + def _on_input_focus(self): self.focused_in() From d7ad673462547ef8c653e2041e71b04594a31d3f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:15:26 +0100 Subject: [PATCH 021/151] removed test data --- openpype/settings/entities/op_version_entity.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index e00c6a4737..a6954119aa 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -37,7 +37,6 @@ class ProductionVersionsInputEntity(_OpenPypeVersionInput): schema_types = ["production-versions-text"] def _get_openpype_versions(self): - return ["", "asd", "dsa", "3.6"] return get_remote_versions(production=True) @@ -45,5 +44,4 @@ class StagingVersionsInputEntity(_OpenPypeVersionInput): schema_types = ["staging-versions-text"] def _get_openpype_versions(self): - return ["", "asd+staging", "dsa+staging", "3.6+staging"] return get_remote_versions(staging=True) From fc9a50f7423ecf7e684396313a1679e2f3d07b44 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:15:54 +0100 Subject: [PATCH 022/151] added special widget for openpype version input --- openpype/settings/entities/op_version_entity.py | 10 +++++----- openpype/tools/settings/settings/categories.py | 7 +++++++ openpype/tools/settings/settings/item_widgets.py | 7 +++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index a6954119aa..2458f03852 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -8,9 +8,9 @@ from .input_entities import TextEntity from .lib import OverrideState -class _OpenPypeVersionInput(TextEntity): +class OpenPypeVersionInput(TextEntity): def _item_initialization(self): - super(_OpenPypeVersionInput, self)._item_initialization() + super(OpenPypeVersionInput, self)._item_initialization() self.multiline = False self.placeholder_text = "Latest" self.value_hints = [] @@ -28,19 +28,19 @@ class _OpenPypeVersionInput(TextEntity): self.value_hints = value_hints - super(_OpenPypeVersionInput, self).set_override_state( + super(OpenPypeVersionInput, self).set_override_state( state, *args, **kwargs ) -class ProductionVersionsInputEntity(_OpenPypeVersionInput): +class ProductionVersionsInputEntity(OpenPypeVersionInput): schema_types = ["production-versions-text"] def _get_openpype_versions(self): return get_remote_versions(production=True) -class StagingVersionsInputEntity(_OpenPypeVersionInput): +class StagingVersionsInputEntity(OpenPypeVersionInput): schema_types = ["staging-versions-text"] def _get_openpype_versions(self): diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index a6e4154b2b..af7e0bd742 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -28,6 +28,9 @@ from openpype.settings.entities import ( StudioDefaultsNotDefined, SchemaError ) +from openpype.settings.entities.op_version_entity import ( + OpenPypeVersionInput +) from openpype.settings import SaveWarningExc from .widgets import ProjectListWidget @@ -46,6 +49,7 @@ from .item_widgets import ( BoolWidget, DictImmutableKeysWidget, TextWidget, + OpenPypeVersionText, NumberWidget, RawJsonWidget, EnumeratorWidget, @@ -116,6 +120,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): elif isinstance(entity, BoolEntity): return BoolWidget(*args) + elif isinstance(entity, OpenPypeVersionInput): + return OpenPypeVersionText(*args) + elif isinstance(entity, TextEntity): return TextWidget(*args) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index f20deeb6e4..11f0dc6add 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -389,6 +389,7 @@ class TextWidget(InputWidget): def _on_entity_change(self): if self.entity.value != self.input_value(): self.set_entity_value() + self._refresh_completer() def set_entity_value(self): if self.entity.multiline: @@ -411,6 +412,12 @@ class TextWidget(InputWidget): self.entity.set(self.input_value()) +class OpenPypeVersionText(TextWidget): + def _on_entity_change(self): + super(OpenPypeVersionText, self)._on_entity_change() + self._refresh_completer() + + class NumberWidget(InputWidget): _slider_widget = None From 8b4455721ed26a5bae3a4d74efe88d03a1854a45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:22:01 +0100 Subject: [PATCH 023/151] removed overdone refresh of completer --- openpype/tools/settings/settings/item_widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 11f0dc6add..bca70edff5 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -389,7 +389,6 @@ class TextWidget(InputWidget): def _on_entity_change(self): if self.entity.value != self.input_value(): self.set_entity_value() - self._refresh_completer() def set_entity_value(self): if self.entity.multiline: From af12e59f826f3b27c3b6c8256650da1360fcffb2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:23:40 +0100 Subject: [PATCH 024/151] skip completer refresh for multiline entities --- openpype/tools/settings/settings/item_widgets.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index bca70edff5..3a7e126846 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -378,9 +378,12 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) - self._refresh_completer() - def _refresh_completer(self): + # Multiline entity can't have completer + # - there is not space for this UI component + if self.entity.multiline: + return + self.input_field.update_completer_values(self.entity.value_hints) def _on_input_focus(self): From 8e853314daac94ce4ecf9966ff85d72c75347a03 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:23:53 +0100 Subject: [PATCH 025/151] added schema validation for value hints --- openpype/settings/entities/input_entities.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index d45e8f9f01..a285bf3433 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -440,6 +440,15 @@ class TextEntity(InputEntity): self.placeholder_text = self.schema_data.get("placeholder") self.value_hints = self.schema_data.get("value_hints") or [] + def schema_validations(self): + if self.multiline and self.value_hints: + reason = ( + "TextEntity entity can't use value hints" + " for multiline input (yet)." + ) + raise EntitySchemaError(self, reason) + super(TextEntity, self).schema_validations() + def _convert_to_valid_type(self, value): # Allow numbers converted to string if isinstance(value, (int, float)): From 298d2da581805e3f704c651bda6ed7e680d58bf4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:57:36 +0100 Subject: [PATCH 026/151] added refresh of completer to init --- openpype/tools/settings/settings/item_widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 3a7e126846..1d912a90b7 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -378,6 +378,8 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + self._refresh_completer() + def _refresh_completer(self): # Multiline entity can't have completer # - there is not space for this UI component From c479fdecdb13085ed4d58c5c044ccf77587c3e76 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 10:36:13 +0100 Subject: [PATCH 027/151] added label --- .../entities/schemas/system_schema/schema_general.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index b848a34dda..ed66cd7ac8 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -30,6 +30,10 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Define explicit OpenPype version that should be used. Keep empty to use latest available version." + }, { "type": "production-versions-text", "key": "production_version", From 1ea180ba8d58fdb4010eefad0739e84d3f1482e9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 11:21:48 +0100 Subject: [PATCH 028/151] removed dot --- .../settings/entities/schemas/system_schema/schema_general.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index ed66cd7ac8..b4c83fc85f 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -20,7 +20,7 @@ }, { "type": "label", - "label": "This is NOT a securely stored password!. It only acts as a simple barrier to stop users from accessing studio wide settings." + "label": "This is NOT a securely stored password! It only acts as a simple barrier to stop users from accessing studio wide settings." }, { "type": "text", From 1454efab3a0528ccaa8cbcb417773fafd8f10e2d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 13:43:51 +0100 Subject: [PATCH 029/151] added style for completer view --- openpype/style/style.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openpype/style/style.css b/openpype/style/style.css index 19245cdc40..0ef15a2511 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -536,6 +536,27 @@ QAbstractItemView::branch:!has-children:!has-siblings:adjoins-item { background: transparent; } +CompleterView { + border: 1px solid #555555; + background: {color:bg-inputs}; +} + +CompleterView::item:selected { + background: {color:bg-view-hover}; +} + +CompleterView::item:selected:hover { + background: {color:bg-view-hover}; +} + +CompleterView::right-arrow { + min-width: 10px; +} +CompleterView::separator { + background: {color:bg-menu-separator}; + height: 2px; + margin-right: 5px; +} /* Progress bar */ QProgressBar { From 44e00ed1d442d3d9dc3cb0a68a8976fb63da401c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 13:44:02 +0100 Subject: [PATCH 030/151] don't care about ideal height --- openpype/tools/settings/settings/widgets.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index bf59f605c9..4b88c1f93f 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -90,10 +90,9 @@ class CompleterView(QtWidgets.QListView): def sizeHint(self): result = super(CompleterView, self).sizeHint() - height = 0 - for row in range(self._filter_model.rowCount()): - height += self.sizeHintForRow(row) - result.setHeight(height) + if self._filter_model.rowCount() == 0: + result.setHeight(0) + return result def _update_geo(self): From 228d6fc914f3f1e6bcd6212d323facb9b67d4364 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 13:45:21 +0100 Subject: [PATCH 031/151] added basic information about valid version --- .../tools/settings/settings/item_widgets.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 1d912a90b7..0c66162375 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -417,9 +417,30 @@ class TextWidget(InputWidget): class OpenPypeVersionText(TextWidget): + def __init__(self, *args, **kwargs): + self._info_widget = None + super(OpenPypeVersionText, self).__init__(*args, **kwargs) + + def create_ui(self): + super(OpenPypeVersionText, self).create_ui() + info_widget = QtWidgets.QLabel("Latest", self) + self.content_layout.addWidget(info_widget, 1) + + self._info_widget = info_widget + + def _update_info_widget(self): + value = self.input_value() + if value == "": + self._info_widget.setText("Latest") + elif value in self.entity.value_hints: + self._info_widget.setText("Ok") + else: + self._info_widget.setText("Version not found from this workstation") + def _on_entity_change(self): super(OpenPypeVersionText, self)._on_entity_change() self._refresh_completer() + self._update_info_widget() class NumberWidget(InputWidget): From 87b606582a95696f4a4fc3091c4cbd624538d0b7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:10:13 +0100 Subject: [PATCH 032/151] do not create new qapplication if already exists --- igniter/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index bbc3dbfc88..d974bd9e0d 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -28,7 +28,9 @@ def open_dialog(): if scale_attr is not None: QtWidgets.QApplication.setAttribute(scale_attr) - app = QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) d = InstallDialog() d.open() @@ -49,7 +51,9 @@ def open_update_window(openpype_version): if scale_attr is not None: QtWidgets.QApplication.setAttribute(scale_attr) - app = QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) d = UpdateWindow(version=openpype_version) d.open() From db467dcbfd041a7fdd60399a2535bf215e40a7c3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:13:56 +0100 Subject: [PATCH 033/151] added function to get openpype icon to tools.py --- igniter/install_dialog.py | 5 +++-- igniter/tools.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 1fe67e3397..251adebc9f 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -12,7 +12,8 @@ from Qt.QtCore import QTimer # noqa from .install_thread import InstallThread from .tools import ( validate_mongo_connection, - get_openpype_path_from_db + get_openpype_path_from_db, + get_openpype_icon_path ) from .nice_progress_bar import NiceProgressBar @@ -187,7 +188,6 @@ class InstallDialog(QtWidgets.QDialog): current_dir = os.path.dirname(os.path.abspath(__file__)) roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") poppins_font_path = os.path.join(current_dir, "Poppins") - icon_path = os.path.join(current_dir, "openpype_icon.png") # Install roboto font QtGui.QFontDatabase.addApplicationFont(roboto_font_path) @@ -196,6 +196,7 @@ class InstallDialog(QtWidgets.QDialog): QtGui.QFontDatabase.addApplicationFont(filename) # Load logo + icon_path = get_openpype_icon_path() pixmap_openpype_logo = QtGui.QPixmap(icon_path) # Set logo as icon of window self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) diff --git a/igniter/tools.py b/igniter/tools.py index 5cad2b9bf8..72b98f1f82 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -210,3 +210,11 @@ def load_stylesheet() -> str: stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" return stylesheet_path.read_text() + + +def get_openpype_icon_path() -> str: + """Path to OpenPype icon png file.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "openpype_icon.png" + ) From e4534c5074244afa096bc290809011d0b24eb7a6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:21:05 +0100 Subject: [PATCH 034/151] added new message dialog to show a dialog --- igniter/__init__.py | 22 ++++++++++++++++++++ igniter/message_dialog.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 igniter/message_dialog.py diff --git a/igniter/__init__.py b/igniter/__init__.py index d974bd9e0d..6f06c1eb90 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -63,9 +63,31 @@ def open_update_window(openpype_version): return version_path +def show_message_dialog(title, message): + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) + from Qt import QtWidgets, QtCore + from .message_dialog import MessageDialog + + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) + + dialog = MessageDialog(title, message) + dialog.open() + + app.exec_() + + __all__ = [ "BootstrapRepos", "open_dialog", "open_update_window", + "show_message_dialog", "version" ] diff --git a/igniter/message_dialog.py b/igniter/message_dialog.py new file mode 100644 index 0000000000..88a086df1e --- /dev/null +++ b/igniter/message_dialog.py @@ -0,0 +1,44 @@ +from Qt import QtWidgets, QtGui + +from .tools import ( + load_stylesheet, + get_openpype_icon_path +) + + +class MessageDialog(QtWidgets.QDialog): + def __init__(self, title, message): + super(MessageDialog, self).__init__() + + # Set logo as icon of window + icon_path = get_openpype_icon_path() + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + # Set title + self.setWindowTitle(title) + + # Set message + label_widget = QtWidgets.QLabel(message, self) + + ok_btn = QtWidgets.QPushButton("OK", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + # layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 1) + layout.addLayout(btns_layout, 0) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self._label_widget = label_widget + self._ok_btn = ok_btn + + def _on_ok_clicked(self): + self.close() + + def showEvent(self, event): + super(MessageDialog, self).showEvent(event) + self.setStyleSheet(load_stylesheet()) From 500339088d51a2c66536aaffd5bd07ea7a4f6619 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:24:25 +0100 Subject: [PATCH 035/151] added new exception into tools --- igniter/tools.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/igniter/tools.py b/igniter/tools.py index 72b98f1f82..2595140582 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -16,6 +16,11 @@ from pymongo.errors import ( ) +class OpenPypeVersionNotFound(Exception): + """OpenPype version was not found in remote and local repository.""" + pass + + def should_add_certificate_path_to_mongo_url(mongo_url): """Check if should add ca certificate to mongo url. From 21b9926be40079b04d970c690d11db8ffb421329 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:30:15 +0100 Subject: [PATCH 036/151] simplified _find_frozen_openpype --- start.py | 120 +++++++++++++++++++++++-------------------------------- 1 file changed, 49 insertions(+), 71 deletions(-) diff --git a/start.py b/start.py index 05b7da6308..91221da75d 100644 --- a/start.py +++ b/start.py @@ -196,7 +196,8 @@ from igniter import BootstrapRepos # noqa: E402 from igniter.tools import ( get_openpype_global_settings, get_openpype_path_from_db, - validate_mongo_connection + validate_mongo_connection, + OpenPypeVersionNotFound ) # noqa from igniter.bootstrap_repos import OpenPypeVersion # noqa: E402 @@ -645,67 +646,60 @@ def _find_frozen_openpype(use_version: str = None, (if requested). """ - version_path = None - openpype_version = None - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - # get local frozen version and add it to detected version so if it is - # newer it will be used instead. - local_version_str = bootstrap.get_version( - Path(os.environ["OPENPYPE_ROOT"])) + # Collect OpenPype versions + openpype_versions = bootstrap.find_openpype( + include_zips=True, + staging=use_staging + ) + openpype_root = Path(os.environ["OPENPYPE_ROOT"]) + local_version_str = bootstrap.get_version(openpype_root) + studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) if local_version_str: local_version = OpenPypeVersion( version=local_version_str, - path=Path(os.environ["OPENPYPE_ROOT"])) + path=openpype_root + ) if local_version not in openpype_versions: openpype_versions.append(local_version) - openpype_versions.sort() - # if latest is currently running, ditch whole list - # and run from current without installing it. - if local_version == openpype_versions[-1]: - os.environ["OPENPYPE_TRYOUT"] = "1" - openpype_versions = [] + + # Find OpenPype version that should be used + openpype_version = None + if use_version is not None: + # Version was specified with arguments or env OPENPYPE_VERSION + # - should crash if version is not available + _print("Finding specified version \"{}\"".format(use_version)) + for version in openpype_versions: + if version == use_version: + openpype_version = version + break + + if openpype_version is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format(use_version) + ) + + elif studio_version: + _print("Finding studio version \"{}\"".format(studio_version)) + # Look for version specified by settings + for version in openpype_versions: + if version == studio_version: + openpype_version = version + break + if openpype_version is None: + raise OpenPypeVersionNotFound(( + "Requested OpenPype version \"{}\" defined by settings" + " was not found." + ).format(use_version)) + else: - _print("!!! Warning: cannot determine current running version.") + # Use latest available version + _print("Finding latest version") + openpype_versions.sort() + openpype_version = openpype_versions[-1] - if not os.getenv("OPENPYPE_TRYOUT"): - try: - # use latest one found (last in the list is latest) - openpype_version = openpype_versions[-1] - except IndexError: - # no OpenPype version found, run Igniter and ask for them. - _print('*** No OpenPype versions found.') - if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": - _print("!!! Cannot open Igniter dialog in headless mode.") - sys.exit(1) - _print("--- launching setup UI ...") - import igniter - return_code = igniter.open_dialog() - if return_code == 2: - os.environ["OPENPYPE_TRYOUT"] = "1" - if return_code == 3: - # run OpenPype after installation - - _print('>>> Finding OpenPype again ...') - openpype_versions = bootstrap.find_openpype( - staging=use_staging) - try: - openpype_version = openpype_versions[-1] - except IndexError: - _print(("!!! Something is wrong and we didn't " - "found it again.")) - sys.exit(1) - elif return_code != 2: - _print(f" . finished ({return_code})") - sys.exit(return_code) - - if not openpype_versions: - # no openpype versions found anyway, lets use then the one - # shipped with frozen OpenPype - if not os.getenv("OPENPYPE_TRYOUT"): - _print("*** Still no luck finding OpenPype.") - _print(("*** We'll try to use the one coming " - "with OpenPype installation.")) + # get local frozen version and add it to detected version so if it is + # newer it will be used instead. + if local_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), @@ -713,22 +707,6 @@ def _find_frozen_openpype(use_version: str = None, _initialize_environment(openpype_version) return version_path - # get path of version specified in `--use-version` - local_version = bootstrap.get_version(OPENPYPE_ROOT) - if use_version and use_version != local_version: - # force the one user has selected - openpype_version = None - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if found: - openpype_version = sorted(found)[-1] - if not openpype_version: - _print(f"!!! Requested version {use_version} was not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) - # test if latest detected is installed (in user data dir) is_inside = False try: From 5d03abe627d890785e8657931559f8dac39d66b1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:30:51 +0100 Subject: [PATCH 037/151] removed play animation part --- start.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/start.py b/start.py index 91221da75d..5f416a47b8 100644 --- a/start.py +++ b/start.py @@ -866,16 +866,6 @@ def boot(): # ------------------------------------------------------------------------ _startup_validations() - # ------------------------------------------------------------------------ - # Play animation - # ------------------------------------------------------------------------ - - # from igniter.terminal_splash import play_animation - - # don't play for silenced commands - # if all(item not in sys.argv for item in silent_commands): - # play_animation() - # ------------------------------------------------------------------------ # Process arguments # ------------------------------------------------------------------------ From 9b369de50c2ad5a281fb0ff84fcb7988f26c224f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:38:43 +0100 Subject: [PATCH 038/151] raise OpenPypeVersionNotFound in run from code too --- start.py | 57 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/start.py b/start.py index 5f416a47b8..69774c3a6c 100644 --- a/start.py +++ b/start.py @@ -769,45 +769,50 @@ def _bootstrap_from_code(use_version, use_staging): # get current version of OpenPype local_version = bootstrap.get_local_live_version() - version_to_use = None openpype_versions = bootstrap.find_openpype( include_zips=True, staging=use_staging) if use_staging and not use_version: - try: - version_to_use = openpype_versions[-1] - except IndexError: - _print("!!! No staging versions are found.") - list_versions(openpype_versions, local_version) - sys.exit(1) + if not openpype_versions: + raise OpenPypeVersionNotFound( + "Didn't find any staging versions." + ) + + # Use last found staging version + version_to_use = openpype_versions[-1] if version_to_use.path.is_file(): version_to_use.path = bootstrap.extract_openpype( version_to_use) bootstrap.add_paths_from_directory(version_to_use.path) os.environ["OPENPYPE_VERSION"] = str(version_to_use) version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 + os.environ["OPENPYPE_REPOS_ROOT"] = ( + version_path / "openpype" + ).as_posix() _openpype_root = version_to_use.path.as_posix() elif use_version and use_version != local_version: - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if found: - version_to_use = sorted(found)[-1] + version_to_use = None + for version in openpype_versions: + if str(version) == use_version: + version_to_use = version + break - if version_to_use: - # use specified - if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) - bootstrap.add_paths_from_directory(version_to_use.path) - os.environ["OPENPYPE_VERSION"] = use_version - version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 - _openpype_root = version_to_use.path.as_posix() - else: - _print(f"!!! Requested version {use_version} was not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) + if version_to_use is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format(use_version) + ) + + # use specified + if version_to_use.path.is_file(): + version_to_use.path = bootstrap.extract_openpype( + version_to_use) + bootstrap.add_paths_from_directory(version_to_use.path) + os.environ["OPENPYPE_VERSION"] = use_version + version_path = version_to_use.path + os.environ["OPENPYPE_REPOS_ROOT"] = ( + version_path / "openpype" + ).as_posix() + _openpype_root = version_to_use.path.as_posix() else: os.environ["OPENPYPE_VERSION"] = local_version version_path = Path(_openpype_root) From a98bdc7d7f2e91ce6c3855d6ca0ed95c6e84be45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:39:24 +0100 Subject: [PATCH 039/151] catch and handle OpenPypeVersioNotFound exception --- start.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/start.py b/start.py index 69774c3a6c..853e6d13a0 100644 --- a/start.py +++ b/start.py @@ -972,6 +972,15 @@ def boot(): # find versions of OpenPype to be used with frozen code try: version_path = _find_frozen_openpype(use_version, use_staging) + except OpenPypeVersionNotFound as exc: + message = str(exc) + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + sys.exit(1) + except RuntimeError as e: # no version to run _print(f"!!! {e}") @@ -984,7 +993,17 @@ def boot(): sys.exit(1) _print(f"--- version is valid") else: - version_path = _bootstrap_from_code(use_version, use_staging) + try: + version_path = _bootstrap_from_code(use_version, use_staging) + + except OpenPypeVersionNotFound as exc: + message = str(exc) + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + sys.exit(1) # set this to point either to `python` from venv in case of live code # or to `openpype` or `openpype_console` in case of frozen code From b0099ea5d4271d997f80932535cc2817574fc73a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:39:45 +0100 Subject: [PATCH 040/151] skipped already done imports --- start.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/start.py b/start.py index 853e6d13a0..32fa4bd062 100644 --- a/start.py +++ b/start.py @@ -521,7 +521,7 @@ def _process_arguments() -> tuple: if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": _print("!!! Cannot open Igniter dialog in headless mode.") sys.exit(1) - import igniter + return_code = igniter.open_dialog() # this is when we want to run OpenPype without installing anything. @@ -719,12 +719,12 @@ def _find_frozen_openpype(use_version: str = None, if not is_inside: # install latest version to user data dir - if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1": - import igniter - version_path = igniter.open_update_window(openpype_version) - else: + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": version_path = bootstrap.install_version( - openpype_version, force=True) + openpype_version, force=True + ) + else: + version_path = igniter.open_update_window(openpype_version) openpype_version.path = version_path _initialize_environment(openpype_version) From d3ac116e70554e08397bc644ed3dc5b6fbbcfb5f Mon Sep 17 00:00:00 2001 From: BenoitConnan Date: Wed, 8 Dec 2021 15:09:10 +0100 Subject: [PATCH 041/151] add options to ReferenceLoader "attach_to_root" : wether or not a group should contain reference --- .../hosts/maya/plugins/load/load_reference.py | 111 +++++++++--------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index cfe8149218..858eb588b1 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -40,85 +40,88 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): except ValueError: family = "model" + group_name = "{}:_GRP".format(namespace) + # True by default to keep legacy behaviours + attach_to_root = options.get("attach_to_root", True) + with maya.maintained_selection(): - groupName = "{}:_GRP".format(namespace) cmds.loadPlugin("AbcImport.mll", quiet=True) nodes = cmds.file(self.fname, namespace=namespace, sharedReferenceFile=False, - groupReference=True, - groupName=groupName, reference=True, - returnNewNodes=True) - - # namespace = cmds.referenceQuery(nodes[0], namespace=True) + returnNewNodes=True, + groupReference=attach_to_root, + groupName=group_name) shapes = cmds.ls(nodes, shapes=True, long=True) - newNodes = (list(set(nodes) - set(shapes))) + new_nodes = (list(set(nodes) - set(shapes))) current_namespace = pm.namespaceInfo(currentNamespace=True) if current_namespace != ":": - groupName = current_namespace + ":" + groupName + group_name = current_namespace + ":" + group_name - groupNode = pm.PyNode(groupName) - roots = set() + self[:] = new_nodes - for node in newNodes: - try: - roots.add(pm.PyNode(node).getAllParents()[-2]) - except: # noqa: E722 - pass + if attach_to_root: + group_node = pm.PyNode(group_name) + roots = set() - if family not in ["layout", "setdress", "mayaAscii", "mayaScene"]: + for node in new_nodes: + try: + roots.add(pm.PyNode(node).getAllParents()[-2]) + except: # noqa: E722 + pass + + if family not in [ + "layout", "setdress", "mayaAscii", "mayaScene"]: + for root in roots: + root.setParent(world=True) + + group_node.zeroTransformPivots() for root in roots: - root.setParent(world=True) + root.setParent(group_node) - groupNode.zeroTransformPivots() - for root in roots: - root.setParent(groupNode) + cmds.setAttr(group_name + ".displayHandle", 1) - cmds.setAttr(groupName + ".displayHandle", 1) + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings['maya']['load']['colors'] + c = colors.get(family) + if c is not None: + group_node.useOutlinerColor.set(1) + group_node.outlinerColor.set( + (float(c[0])/255), + (float(c[1])/255), + (float(c[2])/255)) - settings = get_project_settings(os.environ['AVALON_PROJECT']) - colors = settings['maya']['load']['colors'] - c = colors.get(family) - if c is not None: - groupNode.useOutlinerColor.set(1) - groupNode.outlinerColor.set( - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255) - ) - - self[:] = newNodes - - cmds.setAttr(groupName + ".displayHandle", 1) - # get bounding box - bbox = cmds.exactWorldBoundingBox(groupName) - # get pivot position on world space - pivot = cmds.xform(groupName, q=True, sp=True, ws=True) - # center of bounding box - cx = (bbox[0] + bbox[3]) / 2 - cy = (bbox[1] + bbox[4]) / 2 - cz = (bbox[2] + bbox[5]) / 2 - # add pivot position to calculate offset - cx = cx + pivot[0] - cy = cy + pivot[1] - cz = cz + pivot[2] - # set selection handle offset to center of bounding box - cmds.setAttr(groupName + ".selectHandleX", cx) - cmds.setAttr(groupName + ".selectHandleY", cy) - cmds.setAttr(groupName + ".selectHandleZ", cz) + cmds.setAttr(group_name + ".displayHandle", 1) + # get bounding box + bbox = cmds.exactWorldBoundingBox(group_name) + # get pivot position on world space + pivot = cmds.xform(group_name, q=True, sp=True, ws=True) + # center of bounding box + cx = (bbox[0] + bbox[3]) / 2 + cy = (bbox[1] + bbox[4]) / 2 + cz = (bbox[2] + bbox[5]) / 2 + # add pivot position to calculate offset + cx = cx + pivot[0] + cy = cy + pivot[1] + cz = cz + pivot[2] + # set selection handle offset to center of bounding box + cmds.setAttr(group_name + ".selectHandleX", cx) + cmds.setAttr(group_name + ".selectHandleY", cy) + cmds.setAttr(group_name + ".selectHandleZ", cz) if family == "rig": self._post_process_rig(name, namespace, context, options) else: - if "translate" in options: - cmds.setAttr(groupName + ".t", *options["translate"]) - return newNodes + if "translate" in options: + cmds.setAttr(group_name + ".t", *options["translate"]) + + return new_nodes def switch(self, container, representation): self.update(container, representation) From f88f1a5306b86e20e14dd17b313739107c782f5b Mon Sep 17 00:00:00 2001 From: BenoitConnan Date: Wed, 8 Dec 2021 15:24:24 +0100 Subject: [PATCH 042/151] hound fix --- openpype/hosts/maya/plugins/load/load_reference.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 858eb588b1..dd64fd0a16 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -75,8 +75,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): except: # noqa: E722 pass - if family not in [ - "layout", "setdress", "mayaAscii", "mayaScene"]: + if family not in ["layout", "setdress", + "mayaAscii", "mayaScene"]: for root in roots: root.setParent(world=True) @@ -92,9 +92,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if c is not None: group_node.useOutlinerColor.set(1) group_node.outlinerColor.set( - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255)) + (float(c[0]) / 255), + (float(c[1]) / 255), + (float(c[2]) / 255)) cmds.setAttr(group_name + ".displayHandle", 1) # get bounding box From 0dd3eca360fd10693f3c3a1de6375d55151d7c41 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:25:24 +0100 Subject: [PATCH 043/151] added helper method to change style properties --- openpype/tools/settings/settings/base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index f8378ed18c..bc6c6d10ff 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -26,6 +26,14 @@ class BaseWidget(QtWidgets.QWidget): self.label_widget = None self.create_ui() + @staticmethod + def set_style_property(obj, property_name, property_value): + if obj.property(property_name) == property_value: + return + + obj.setProperty(property_name, property_value) + obj.style().polish(obj) + def scroll_to(self, widget): self.category_widget.scroll_to(widget) From 0479d544bd3c23539c3367ac2dada2d8345c7ad0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:25:41 +0100 Subject: [PATCH 044/151] update geo on show of completer view --- openpype/tools/settings/settings/widgets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 4b88c1f93f..bfe8339219 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -95,6 +95,10 @@ class CompleterView(QtWidgets.QListView): return result + def showEvent(self, event): + super(CompleterView, self).showEvent(event) + self._update_geo() + def _update_geo(self): size_hint = self.sizeHint() self.resize(size_hint.width(), size_hint.height()) From ccce04309bac2318a6ebf87916b0f646e3204dd1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:26:33 +0100 Subject: [PATCH 045/151] renamed exception BaseInvalidValueType to BaseInvalidValue --- openpype/settings/entities/__init__.py | 4 ++-- openpype/settings/entities/base_entity.py | 4 ++-- openpype/settings/entities/color_entity.py | 6 +++--- openpype/settings/entities/exceptions.py | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 4efd358297..a173e2454f 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -57,7 +57,7 @@ from .exceptions import ( SchemaError, DefaultsNotDefined, StudioDefaultsNotDefined, - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType, InvalidKeySymbols, SchemaMissingFileInfo, @@ -130,7 +130,7 @@ from .op_version_entity import ( __all__ = ( "DefaultsNotDefined", "StudioDefaultsNotDefined", - "BaseInvalidValueType", + "BaseInvalidValue", "InvalidValueType", "InvalidKeySymbols", "SchemaMissingFileInfo", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 341968bd75..12754d345f 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -9,7 +9,7 @@ from .lib import ( ) from .exceptions import ( - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType, SchemeGroupHierarchyBug, EntitySchemaError @@ -432,7 +432,7 @@ class BaseItemEntity(BaseEntity): try: new_value = self.convert_to_valid_type(value) - except BaseInvalidValueType: + except BaseInvalidValue: new_value = NOT_SET if new_value is not NOT_SET: diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index 3becf2d865..bdaab6f583 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -1,7 +1,7 @@ from .lib import STRING_TYPE from .input_entities import InputEntity from .exceptions import ( - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType ) @@ -47,7 +47,7 @@ class ColorEntity(InputEntity): reason = "Color entity expect 4 items in list got {}".format( len(value) ) - raise BaseInvalidValueType(reason, self.path) + raise BaseInvalidValue(reason, self.path) new_value = [] for item in value: @@ -60,7 +60,7 @@ class ColorEntity(InputEntity): reason = ( "Color entity expect 4 integers in range 0-255 got {}" ).format(value) - raise BaseInvalidValueType(reason, self.path) + raise BaseInvalidValue(reason, self.path) new_value.append(item) # Make sure diff --git a/openpype/settings/entities/exceptions.py b/openpype/settings/entities/exceptions.py index f352c94f20..d1728a7b12 100644 --- a/openpype/settings/entities/exceptions.py +++ b/openpype/settings/entities/exceptions.py @@ -15,14 +15,14 @@ class StudioDefaultsNotDefined(Exception): super(StudioDefaultsNotDefined, self).__init__(msg) -class BaseInvalidValueType(Exception): +class BaseInvalidValue(Exception): def __init__(self, reason, path): msg = "Path \"{}\". {}".format(path, reason) self.msg = msg - super(BaseInvalidValueType, self).__init__(msg) + super(BaseInvalidValue, self).__init__(msg) -class InvalidValueType(BaseInvalidValueType): +class InvalidValueType(BaseInvalidValue): def __init__(self, valid_types, invalid_type, path): joined_types = ", ".join( [str(valid_type) for valid_type in valid_types] From 703368c5b0eaddc93172d6487ac1b5dcb0e5f91b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:27:02 +0100 Subject: [PATCH 046/151] added validation of version value with convert_to_valid_type --- .../settings/entities/op_version_entity.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 2458f03852..20495a0d42 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -2,10 +2,15 @@ from openpype.lib.openpype_version import ( op_version_control_available, get_remote_versions, openpype_path_is_set, - openpype_path_is_accessible + openpype_path_is_accessible, + get_OpenPypeVersion ) from .input_entities import TextEntity -from .lib import OverrideState +from .lib import ( + OverrideState, + NOT_SET +) +from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): @@ -32,6 +37,21 @@ class OpenPypeVersionInput(TextEntity): state, *args, **kwargs ) + def convert_to_valid_type(self, value): + if value and value is not NOT_SET: + OpenPypeVersion = get_OpenPypeVersion() + if OpenPypeVersion is not None: + try: + OpenPypeVersion(version=value) + except Exception: + raise BaseInvalidValue( + "Value \"{}\"is not valid version format.".format( + value + ), + self.path + ) + return super(OpenPypeVersionInput, self).convert_to_valid_type(value) + class ProductionVersionsInputEntity(OpenPypeVersionInput): schema_types = ["production-versions-text"] From 9acdf2e338ebd44c4321035b4e885e863c81deab Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:27:27 +0100 Subject: [PATCH 047/151] added style to info label --- openpype/style/data.json | 4 +++- openpype/style/style.css | 8 ++++++++ openpype/tools/settings/settings/item_widgets.py | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index 026eaf4264..62573f015e 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -111,7 +111,9 @@ "focus-border": "#839caf", "image-btn": "#bfccd6", "image-btn-hover": "#189aea", - "image-btn-disabled": "#bfccd6" + "image-btn-disabled": "#bfccd6", + "version-exists": "#458056", + "version-not-found": "#ffc671" } } } diff --git a/openpype/style/style.css b/openpype/style/style.css index 0ef15a2511..9249db5f1e 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1158,6 +1158,14 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border-radius: 5px; } +#OpenPypeVersionLabel[state="success"] { + color: {color:settings:version-exists}; +} + +#OpenPypeVersionLabel[state="warning"] { + color: {color:settings:version-not-found}; +} + #ShadowWidget { font-size: 36pt; } diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 0c66162375..3a7a107965 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -424,6 +424,7 @@ class OpenPypeVersionText(TextWidget): def create_ui(self): super(OpenPypeVersionText, self).create_ui() info_widget = QtWidgets.QLabel("Latest", self) + info_widget.setObjectName("OpenPypeVersionLabel") self.content_layout.addWidget(info_widget, 1) self._info_widget = info_widget From 84022f9e0def952a44a7cbea3f35b690705c2c78 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:27:53 +0100 Subject: [PATCH 048/151] added more variants of info label with colors and ivalidation --- .../tools/settings/settings/item_widgets.py | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 3a7a107965..9f78060c87 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -4,6 +4,7 @@ from Qt import QtWidgets, QtCore, QtGui from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET +from openpype.settings.entities.exceptions import BaseInvalidValue from .widgets import ( ExpandingWidget, @@ -423,7 +424,7 @@ class OpenPypeVersionText(TextWidget): def create_ui(self): super(OpenPypeVersionText, self).create_ui() - info_widget = QtWidgets.QLabel("Latest", self) + info_widget = QtWidgets.QLabel(self) info_widget.setObjectName("OpenPypeVersionLabel") self.content_layout.addWidget(info_widget, 1) @@ -431,17 +432,67 @@ class OpenPypeVersionText(TextWidget): def _update_info_widget(self): value = self.input_value() - if value == "": - self._info_widget.setText("Latest") + + message = "" + tooltip = "" + state = None + if self._is_invalid: + message = "Invalid version format" + + elif value == "": + message = "Use latest available version" + tooltip = "Latest version from OpenPype zip repository will used" + elif value in self.entity.value_hints: - self._info_widget.setText("Ok") + message = "Version {} will be used".format(value) + state = "success" + else: - self._info_widget.setText("Version not found from this workstation") + message = ( + "Version {} not found in listed versions".format(value) + ) + state = "warning" + if self.entity.value_hints: + tooltip = "Found versions are {}".format(", ".join( + ['"{}"'.format(hint) for hint in self.entity.value_hints] + )) + else: + tooltip = "No versions were found" + + self._info_widget.setText(message) + self._info_widget.setToolTip(tooltip) + self.set_style_property(self._info_widget, "state", state) + + def set_entity_value(self): + super(OpenPypeVersionText, self).set_entity_value() + self._invalidate() + self._update_info_widget() + + def _on_value_change_timer(self): + value = self.input_value() + self._invalidate() + if not self.is_invalid: + self.entity.set(value) + self.update_style() + else: + # Manually trigger hierachical style update + self.ignore_input_changes.set_ignore(True) + self.ignore_input_changes.set_ignore(False) + + self._update_info_widget() + + def _invalidate(self): + value = self.input_value() + try: + self.entity.convert_to_valid_type(value) + is_invalid = False + except BaseInvalidValue: + is_invalid = True + self._is_invalid = is_invalid def _on_entity_change(self): super(OpenPypeVersionText, self)._on_entity_change() self._refresh_completer() - self._update_info_widget() class NumberWidget(InputWidget): From 5181c52d12a2a7e3b2688d01ea7f05df73379d70 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:29:49 +0100 Subject: [PATCH 049/151] modified messages a little bit --- openpype/tools/settings/settings/item_widgets.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 9f78060c87..22f672da2b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -437,27 +437,29 @@ class OpenPypeVersionText(TextWidget): tooltip = "" state = None if self._is_invalid: - message = "Invalid version format" + message = "Invalid OpenPype version format" elif value == "": message = "Use latest available version" - tooltip = "Latest version from OpenPype zip repository will used" + tooltip = ( + "Latest version from OpenPype zip repository will be used" + ) elif value in self.entity.value_hints: - message = "Version {} will be used".format(value) state = "success" + message = "Version {} will be used".format(value) else: + state = "warning" message = ( "Version {} not found in listed versions".format(value) ) - state = "warning" if self.entity.value_hints: - tooltip = "Found versions are {}".format(", ".join( + tooltip = "Listed versions: {}".format(", ".join( ['"{}"'.format(hint) for hint in self.entity.value_hints] )) else: - tooltip = "No versions were found" + tooltip = "No versions were listed" self._info_widget.setText(message) self._info_widget.setToolTip(tooltip) From 1588df3ca275165a3fb143ddcd5c9d4b4af92ea8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:37:03 +0100 Subject: [PATCH 050/151] added placeholder inheritance back --- openpype/tools/settings/settings/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 3e44bd3969..cc6e396db0 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -185,7 +185,7 @@ class CompleterView(QtWidgets.QListView): self._on_activated(index) -class SettingsLineEdit(QtWidgets.QLineEdit): +class SettingsLineEdit(PlaceholderLineEdit): focused_in = QtCore.Signal() def __init__(self, *args, **kwargs): From 767ef9d715c92f50493a1d20352e3df5ecaff63a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 18:31:14 +0100 Subject: [PATCH 051/151] sort versions earlier --- start.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/start.py b/start.py index 32fa4bd062..55008a9526 100644 --- a/start.py +++ b/start.py @@ -662,6 +662,8 @@ def _find_frozen_openpype(use_version: str = None, if local_version not in openpype_versions: openpype_versions.append(local_version) + openpype_versions.sort() + # Find OpenPype version that should be used openpype_version = None if use_version is not None: @@ -694,7 +696,6 @@ def _find_frozen_openpype(use_version: str = None, else: # Use latest available version _print("Finding latest version") - openpype_versions.sort() openpype_version = openpype_versions[-1] # get local frozen version and add it to detected version so if it is From ce204157273e74fd15abd9a37eafbd7722e533a7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 18:31:40 +0100 Subject: [PATCH 052/151] added option to pass "latest" to use version argument --- start.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/start.py b/start.py index 55008a9526..28c04600c4 100644 --- a/start.py +++ b/start.py @@ -667,13 +667,17 @@ def _find_frozen_openpype(use_version: str = None, # Find OpenPype version that should be used openpype_version = None if use_version is not None: - # Version was specified with arguments or env OPENPYPE_VERSION - # - should crash if version is not available - _print("Finding specified version \"{}\"".format(use_version)) - for version in openpype_versions: - if version == use_version: - openpype_version = version - break + if use_version.lower() == "latest": + openpype_version = openpype_versions[-1] + else: + use_version_obj = OpenPypeVersion(use_version) + # Version was specified with arguments or env OPENPYPE_VERSION + # - should crash if version is not available + _print("Finding specified version \"{}\"".format(use_version)) + for version in openpype_versions: + if version == use_version_obj: + openpype_version = version + break if openpype_version is None: raise OpenPypeVersionNotFound( From 52c5424f2b59987876eb0412dfbdab2c8af0cd09 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 18:31:50 +0100 Subject: [PATCH 053/151] modified messages --- start.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/start.py b/start.py index 28c04600c4..6fa624b5cd 100644 --- a/start.py +++ b/start.py @@ -681,7 +681,9 @@ def _find_frozen_openpype(use_version: str = None, if openpype_version is None: raise OpenPypeVersionNotFound( - "Requested version \"{}\" was not found.".format(use_version) + "Requested version \"{}\" was not found.".format( + use_version + ) ) elif studio_version: @@ -695,7 +697,7 @@ def _find_frozen_openpype(use_version: str = None, raise OpenPypeVersionNotFound(( "Requested OpenPype version \"{}\" defined by settings" " was not found." - ).format(use_version)) + ).format(studio_version)) else: # Use latest available version From d2edf3da743459bf3b043d5c855098b04cb66e79 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:02:37 +0100 Subject: [PATCH 054/151] don't return latest version --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 9446b3e8ce..3db9dd0b91 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -589,7 +589,7 @@ class OpenPypeVersion(semver.VersionInfo): """ result = get_expected_studio_version_str(staging) if not result: - return cls.get_latest_version(staging, False) + return None return OpenPypeVersion(version=result) From 02b8e57a81a2b4c9782dc69a57e7b53c29598d8a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:03:16 +0100 Subject: [PATCH 055/151] return None from get_latest_version if there are any openpype versions available --- igniter/bootstrap_repos.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 3db9dd0b91..163db07f59 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -559,7 +559,8 @@ class OpenPypeVersion(semver.VersionInfo): staging: bool = False, local: bool = False) -> OpenPypeVersion: """Get latest available version. - This is utility version to get latest version from all found. + This is utility version to get latest version from all found except + build. Args: staging (bool, optional): List staging versions if True. @@ -572,6 +573,8 @@ class OpenPypeVersion(semver.VersionInfo): openpype_versions = OpenPypeVersion.get_available_versions( staging, local) + if not openpype_versions: + return None return openpype_versions[-1] @classmethod From a160e2229068007f28a320800c0d983784f11405 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:03:29 +0100 Subject: [PATCH 056/151] have ability to get build version --- igniter/bootstrap_repos.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 163db07f59..920eb5fa6b 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -554,6 +554,19 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) + @staticmethod + def get_build_version(): + """Get version of OpenPype inside build.""" + openpype_root = Path(os.environ["OPENPYPE_ROOT"]) + build_version_str = BootstrapRepos.get_version(openpype_root) + build_version = None + if build_version_str: + build_version = OpenPypeVersion( + version=build_version_str, + path=openpype_root + ) + return build_version + @staticmethod def get_latest_version( staging: bool = False, local: bool = False) -> OpenPypeVersion: From 406ece7e44db78e44d365632ec6ac1ddaf8494fe Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:03:55 +0100 Subject: [PATCH 057/151] use new methods in start.py --- start.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/start.py b/start.py index 6fa624b5cd..80baff5496 100644 --- a/start.py +++ b/start.py @@ -651,16 +651,11 @@ def _find_frozen_openpype(use_version: str = None, include_zips=True, staging=use_staging ) - openpype_root = Path(os.environ["OPENPYPE_ROOT"]) - local_version_str = bootstrap.get_version(openpype_root) + local_version = OpenPypeVersion.get_build_version() + if local_version not in openpype_versions: + openpype_versions.append(local_version) + studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) - if local_version_str: - local_version = OpenPypeVersion( - version=local_version_str, - path=openpype_root - ) - if local_version not in openpype_versions: - openpype_versions.append(local_version) openpype_versions.sort() @@ -668,6 +663,7 @@ def _find_frozen_openpype(use_version: str = None, openpype_version = None if use_version is not None: if use_version.lower() == "latest": + _print("Finding latest version") openpype_version = openpype_versions[-1] else: use_version_obj = OpenPypeVersion(use_version) From 715e1a6d32e19fe53ecd3c2061fac6cd87549f04 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:06:16 +0100 Subject: [PATCH 058/151] cache build version is it probably won't change during process time --- igniter/bootstrap_repos.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 920eb5fa6b..5e8efd1cc4 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -63,6 +63,7 @@ class OpenPypeVersion(semver.VersionInfo): staging = False path = None _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 + _build_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. @@ -554,18 +555,20 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) - @staticmethod - def get_build_version(): + @classmethod + def get_build_version(cls): """Get version of OpenPype inside build.""" - openpype_root = Path(os.environ["OPENPYPE_ROOT"]) - build_version_str = BootstrapRepos.get_version(openpype_root) - build_version = None - if build_version_str: - build_version = OpenPypeVersion( - version=build_version_str, - path=openpype_root - ) - return build_version + if cls._build_version is None: + openpype_root = Path(os.environ["OPENPYPE_ROOT"]) + build_version_str = BootstrapRepos.get_version(openpype_root) + build_version = None + if build_version_str: + build_version = OpenPypeVersion( + version=build_version_str, + path=openpype_root + ) + cls._build_version = build_version + return cls._build_version @staticmethod def get_latest_version( From 02686ff096cb3dafd51aa74d1d288c6b7c556506 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:31:29 +0100 Subject: [PATCH 059/151] added get build version --- openpype/lib/openpype_version.py | 10 ++++++++++ openpype/settings/entities/op_version_entity.py | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 42ee454378..7d9dc6ef94 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -20,6 +20,16 @@ def op_version_control_available(): return True +def get_build_version(): + """Get OpenPype version inside build. + + This version is not returned by any other functions here. + """ + if op_version_control_available(): + return get_OpenPypeVersion().get_build_version() + return None + + def get_available_versions(*args, **kwargs): """Get list of available versions.""" if op_version_control_available(): diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 20495a0d42..cf2150f12e 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -3,7 +3,8 @@ from openpype.lib.openpype_version import ( get_remote_versions, openpype_path_is_set, openpype_path_is_accessible, - get_OpenPypeVersion + get_OpenPypeVersion, + get_build_version ) from .input_entities import TextEntity from .lib import ( @@ -57,11 +58,15 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): schema_types = ["production-versions-text"] def _get_openpype_versions(self): - return get_remote_versions(production=True) + versions = get_remote_versions(staging=False, production=True) + versions.append(get_build_version()) + return sorted(versions) class StagingVersionsInputEntity(OpenPypeVersionInput): schema_types = ["staging-versions-text"] def _get_openpype_versions(self): - return get_remote_versions(staging=True) + versions = get_remote_versions(staging=True, production=False) + versions.append(get_build_version()) + return sorted(versions) From b3c1bbd84ee31795a3425d5fd245c2d639b8b58f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:31:36 +0100 Subject: [PATCH 060/151] removed unused imports --- openpype/settings/entities/enum_entity.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 7c8721556f..fb6099e82a 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,10 +1,4 @@ import copy -from openpype.lib.openpype_version import ( - op_version_control_available, - get_remote_versions, - openpype_path_is_set, - openpype_path_is_accessible -) from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( From c05ec98b6049202aacb3273ed2d5ea38b97b5e00 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:32:07 +0100 Subject: [PATCH 061/151] removed current verions functions and replace then with get_expected_studio_version --- openpype/lib/openpype_version.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 7d9dc6ef94..9a92454eca 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -2,7 +2,11 @@ Access to logic from igniter is available only for OpenPype processes. Is meant to be able check OpenPype versions for studio. The logic is dependent -on igniter's logic of processing. +on igniter's inner logic of versions. + +Keep in mind that all functions except 'get_build_version' does not return +OpenPype version located in build but versions available in remote versions +repository or locally available. """ import sys @@ -40,42 +44,42 @@ def get_available_versions(*args, **kwargs): def openpype_path_is_set(): + """OpenPype repository path is set in settings.""" if op_version_control_available(): return get_OpenPypeVersion().openpype_path_is_set() return None def openpype_path_is_accessible(): + """OpenPype version repository path can be accessed.""" if op_version_control_available(): return get_OpenPypeVersion().openpype_path_is_accessible() return None def get_local_versions(*args, **kwargs): + """OpenPype versions available on this workstation.""" if op_version_control_available(): return get_OpenPypeVersion().get_local_versions(*args, **kwargs) return None def get_remote_versions(*args, **kwargs): + """OpenPype versions in repository path.""" if op_version_control_available(): return get_OpenPypeVersion().get_remote_versions(*args, **kwargs) return None def get_latest_version(*args, **kwargs): + """Get latest version from repository path.""" if op_version_control_available(): return get_OpenPypeVersion().get_latest_version(*args, **kwargs) return None -def get_current_production_version(): +def get_expected_studio_version(staging=False): + """Expected production or staging version in studio.""" if op_version_control_available(): - return get_OpenPypeVersion().get_production_version() - return None - - -def get_current_staging_version(): - if op_version_control_available(): - return get_OpenPypeVersion().get_staging_version() + return get_OpenPypeVersion().get_expected_studio_version(staging) return None From 1a60f7fa0f047132c79a9a148b4ca8d1a474977e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 11:33:32 +0100 Subject: [PATCH 062/151] keep previous find_openpype implementation --- igniter/bootstrap_repos.py | 62 ++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 5e8efd1cc4..9e58f5bad2 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1115,21 +1115,65 @@ class BootstrapRepos: @staticmethod def find_openpype( + self, openpype_path: Union[Path, str] = None, staging: bool = False, include_zips: bool = False) -> Union[List[OpenPypeVersion], None]: + """Get ordered dict of detected OpenPype version. + Resolution order for OpenPype is following: + + 1) First we test for ``OPENPYPE_PATH`` environment variable + 2) We try to find ``openPypePath`` in registry setting + 3) We use user data directory + + Args: + openpype_path (Path or str, optional): Try to find OpenPype on + the given path or url. + staging (bool, optional): Filter only staging version, skip them + otherwise. + include_zips (bool, optional): If set True it will try to find + OpenPype in zip files in given directory. + + Returns: + dict of Path: Dictionary of detected OpenPype version. + Key is version, value is path to zip file. + + None: if OpenPype is not found. + + Todo: + implement git/url support as OpenPype location, so it would be + possible to enter git url, OpenPype would check it out and if it is + ok install it as normal version. + + """ + if openpype_path and not isinstance(openpype_path, Path): + raise NotImplementedError( + ("Finding OpenPype in non-filesystem locations is" + " not implemented yet.")) + + dir_to_search = self.data_dir + user_versions = self.get_openpype_versions(self.data_dir, staging) + # if we have openpype_path specified, search only there. if openpype_path: - openpype_versions = OpenPypeVersion.get_versions_from_directory( - openpype_path) - # filter out staging - - openpype_versions = [ - v for v in openpype_versions if v.is_staging() == staging - ] - + dir_to_search = openpype_path else: - openpype_versions = OpenPypeVersion.get_available_versions(staging) + if os.getenv("OPENPYPE_PATH"): + if Path(os.getenv("OPENPYPE_PATH")).exists(): + dir_to_search = Path(os.getenv("OPENPYPE_PATH")) + else: + try: + registry_dir = Path( + str(self.registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + openpype_versions = self.get_openpype_versions(dir_to_search, staging) + openpype_versions += user_versions # remove zip file version if needed. if not include_zips: From 77730e8d2b3b2e13026e77a618271936e5a2a7b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 11:35:03 +0100 Subject: [PATCH 063/151] added new methods to find openpype versions --- igniter/bootstrap_repos.py | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 9e58f5bad2..a258f71a20 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1114,6 +1114,64 @@ class BootstrapRepos: os.environ["PYTHONPATH"] = os.pathsep.join(paths) @staticmethod + def find_openpype_version(version, staging): + if isinstance(version, str): + version = OpenPypeVersion(version=version) + + build_version = OpenPypeVersion.get_build_version() + if build_version == version: + return build_version + + local_versions = OpenPypeVersion.get_local_versions( + staging=staging, production=not staging + ) + zip_version = None + for local_version in local_versions: + if local_version == version: + if local_version.suffix.lower() == ".zip": + zip_version = local_version + else: + return local_version + + if zip_version is not None: + return zip_version + + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging, production=not staging + ) + for remote_version in remote_versions: + if remote_version == version: + return remote_version + return None + + @staticmethod + def find_latest_openpype_version(staging): + build_version = OpenPypeVersion.get_build_version() + local_versions = OpenPypeVersion.get_local_versions( + staging=staging + ) + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging + ) + all_versions = local_versions + remote_versions + if not staging: + all_versions.append(build_version) + + if not all_versions: + return None + + all_versions.sort() + latest_version = all_versions[-1] + if latest_version == build_version: + return latest_version + + if not latest_version.path.is_dir(): + for version in local_versions: + if version == latest_version and version.path.is_dir(): + latest_version = version + break + return latest_version + def find_openpype( self, openpype_path: Union[Path, str] = None, From d22214415c17a8037353406c90a509954dcce8ac Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 11:59:57 +0100 Subject: [PATCH 064/151] simplified get build version --- igniter/bootstrap_repos.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index a258f71a20..7a367e6f09 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -561,13 +561,11 @@ class OpenPypeVersion(semver.VersionInfo): if cls._build_version is None: openpype_root = Path(os.environ["OPENPYPE_ROOT"]) build_version_str = BootstrapRepos.get_version(openpype_root) - build_version = None if build_version_str: - build_version = OpenPypeVersion( + cls._build_version = OpenPypeVersion( version=build_version_str, path=openpype_root ) - cls._build_version = build_version return cls._build_version @staticmethod From bc301fc7893551767763545e689d6ade875da428 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:02:19 +0100 Subject: [PATCH 065/151] modified get latest versions --- igniter/bootstrap_repos.py | 56 ++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 7a367e6f09..7de4c5db4b 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -570,26 +570,58 @@ class OpenPypeVersion(semver.VersionInfo): @staticmethod def get_latest_version( - staging: bool = False, local: bool = False) -> OpenPypeVersion: + staging: bool = False, + local: bool = None, + remote: bool = None + ) -> OpenPypeVersion: """Get latest available version. - This is utility version to get latest version from all found except - build. + The version does not contain information about path and source. + + This is utility version to get latest version from all found. Build + version is not listed if staging is enabled. + + Arguments 'local' and 'remote' define if local and remote repository + versions are used. All versions are used if both are not set (or set + to 'None'). If only one of them is set to 'True' the other is disabled. + It is possible to set both to 'True' (same as both set to None) and to + 'False' in that case only build version can be used. Args: staging (bool, optional): List staging versions if True. - local (bool, optional): List only local versions. - - See also: - OpenPypeVersion.get_available_versions() - + local (bool, optional): List local versions if True. + remote (bool, optional): List remote versions if True. """ - openpype_versions = OpenPypeVersion.get_available_versions( - staging, local) + if local is None and remote is None: + local = True + remote = True - if not openpype_versions: + elif local is None and not remote: + local = True + + elif remote is None and not local: + remote = True + + build_version = OpenPypeVersion.get_build_version() + local_versions = [] + remote_versions = [] + if local: + local_versions = OpenPypeVersion.get_local_versions( + staging=staging + ) + if remote: + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging + ) + all_versions = local_versions + remote_versions + if not staging: + all_versions.append(build_version) + + if not all_versions: return None - return openpype_versions[-1] + + all_versions.sort() + return all_versions[-1] @classmethod def get_expected_studio_version(cls, staging=False): From 105e6241b46da79102d50fb84c7376155478a31a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:02:32 +0100 Subject: [PATCH 066/151] fixed zip check --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 7de4c5db4b..2f49fee064 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1158,7 +1158,7 @@ class BootstrapRepos: zip_version = None for local_version in local_versions: if local_version == version: - if local_version.suffix.lower() == ".zip": + if local_version.path.suffix.lower() == ".zip": zip_version = local_version else: return local_version From 3e04f294e09d98102a673ebc5182258a07866070 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:02:44 +0100 Subject: [PATCH 067/151] removed unused method --- igniter/bootstrap_repos.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 2f49fee064..77fcd15f1e 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -477,39 +477,6 @@ class OpenPypeVersion(semver.VersionInfo): filtered_versions.append(version) return list(sorted(set(filtered_versions))) - @classmethod - def get_available_versions( - cls, staging: bool = False, local: bool = False) -> List: - """Get ordered dict of detected OpenPype version. - - Resolution order for OpenPype is following: - - 1) First we test for ``OPENPYPE_PATH`` environment variable - 2) We try to find ``openPypePath`` in registry setting - 3) We use user data directory - - Only versions from 3) will be listed when ``local`` is set to True. - - Args: - staging (bool, optional): List staging versions if True. - local (bool, optional): List only local versions. - - """ - user_versions = cls.get_local_versions() - # if we have openpype_path specified, search only there. - openpype_versions = [] - if not local: - openpype_versions = cls.get_remote_versions() - openpype_versions += user_versions - - # remove duplicates and staging/production - openpype_versions = [ - v for v in openpype_versions if v.is_staging() == staging - ] - openpype_versions = sorted(list(set(openpype_versions))) - - return openpype_versions - @staticmethod def get_versions_from_directory(openpype_dir: Path) -> List: """Get all detected OpenPype versions in directory. From d71b8e2e40d2215a7b7c644d2debbf6549e7e171 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:03:23 +0100 Subject: [PATCH 068/151] modified use version argument handling to be able pass "latest" --- start.py | 48 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/start.py b/start.py index 80baff5496..29afb759c5 100644 --- a/start.py +++ b/start.py @@ -467,23 +467,41 @@ def _process_arguments() -> tuple: use_version = None use_staging = False commands = [] - for arg in sys.argv: - if arg == "--use-version": - _print("!!! Please use option --use-version like:") - _print(" --use-version=3.0.0") - sys.exit(1) - if arg.startswith("--use-version="): - m = re.search( - r"--use-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) - if m and m.group('version'): - use_version = m.group('version') - _print(">>> Requested version [ {} ]".format(use_version)) - sys.argv.remove(arg) - if "+staging" in use_version: - use_staging = True - break + # OpenPype version specification through arguments + use_version_arg = "--use-version" + + for arg in sys.argv: + if arg.startswith(use_version_arg): + # Remove arg from sys argv + sys.argv.remove(arg) + # Extract string after use version arg + use_version_value = arg[len(use_version_arg):] + + if ( + not use_version_value + or not use_version_value.startswith("=") + ): + _print("!!! Please use option --use-version like:") + _print(" --use-version=3.0.0") + sys.exit(1) + + version_str = use_version_value[1:] + use_version = None + if version_str.lower() == "latest": + use_version = "latest" else: + m = re.search( + r"(?P\d+\.\d+\.\d+(?:\S*)?)", version_str + ) + if m and m.group('version'): + use_version = m.group('version') + _print(">>> Requested version [ {} ]".format(use_version)) + if "+staging" in use_version: + use_staging = True + break + + if use_version is None: _print("!!! Requested version isn't in correct format.") _print((" Use --list-versions to find out" " proper version string.")) From 27df5b5d924b18c0fcd8c0ae321a3464070e37c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:03:50 +0100 Subject: [PATCH 069/151] use new methods in bootstrap from code --- start.py | 66 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/start.py b/start.py index 29afb759c5..674cc66a0f 100644 --- a/start.py +++ b/start.py @@ -781,6 +781,13 @@ def _bootstrap_from_code(use_version, use_staging): # run through repos and add them to `sys.path` and `PYTHONPATH` # set root _openpype_root = OPENPYPE_ROOT + # Unset use version if latest should be used + # - when executed from code then code is expected as latest + # - when executed from build then build is already marked as latest + # in '_find_frozen_openpype' + if use_version and use_version.lower() == "latest": + use_version = None + if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) switch_str = f" - will switch to {use_version}" if use_version else "" @@ -790,43 +797,35 @@ def _bootstrap_from_code(use_version, use_staging): # get current version of OpenPype local_version = bootstrap.get_local_live_version() - openpype_versions = bootstrap.find_openpype( - include_zips=True, staging=use_staging) - if use_staging and not use_version: - if not openpype_versions: - raise OpenPypeVersionNotFound( - "Didn't find any staging versions." + # All cases when should be used different version than build + if (use_version and use_version != local_version) or use_staging: + if use_version: + # Explicit version should be used + version_to_use = bootstrap.find_openpype_version( + use_version, use_staging ) - - # Use last found staging version - version_to_use = openpype_versions[-1] - if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) - bootstrap.add_paths_from_directory(version_to_use.path) - os.environ["OPENPYPE_VERSION"] = str(version_to_use) - version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = ( - version_path / "openpype" - ).as_posix() - _openpype_root = version_to_use.path.as_posix() - - elif use_version and use_version != local_version: - version_to_use = None - for version in openpype_versions: - if str(version) == use_version: - version_to_use = version - break - - if version_to_use is None: - raise OpenPypeVersionNotFound( - "Requested version \"{}\" was not found.".format(use_version) + if version_to_use is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format( + use_version + ) + ) + else: + # Staging version should be used + version_to_use = bootstrap.find_latest_openpype_version( + use_staging ) + if version_to_use is None: + if use_staging: + reason = "Didn't find any staging versions." + else: + # This reason is backup for possible bug in code + reason = "Didn't find any versions." + raise OpenPypeVersionNotFound(reason) - # use specified + # Start extraction of version if needed if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) + version_to_use.path = bootstrap.extract_openpype(version_to_use) bootstrap.add_paths_from_directory(version_to_use.path) os.environ["OPENPYPE_VERSION"] = use_version version_path = version_to_use.path @@ -834,6 +833,7 @@ def _bootstrap_from_code(use_version, use_staging): version_path / "openpype" ).as_posix() _openpype_root = version_to_use.path.as_posix() + else: os.environ["OPENPYPE_VERSION"] = local_version version_path = Path(_openpype_root) From d219ff0cd18219051a00de65ccfa9c6990d94e5b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:04:13 +0100 Subject: [PATCH 070/151] use new methods in find frozen openpype --- start.py | 59 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/start.py b/start.py index 674cc66a0f..58c88df340 100644 --- a/start.py +++ b/start.py @@ -665,33 +665,25 @@ def _find_frozen_openpype(use_version: str = None, """ # Collect OpenPype versions - openpype_versions = bootstrap.find_openpype( - include_zips=True, - staging=use_staging - ) - local_version = OpenPypeVersion.get_build_version() - if local_version not in openpype_versions: - openpype_versions.append(local_version) - + build_version = OpenPypeVersion.get_build_version() + # Expected version that should be used by studio settings + # - this option is used only if version is not explictly set and if + # studio has set explicit version in settings studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) - openpype_versions.sort() - - # Find OpenPype version that should be used - openpype_version = None if use_version is not None: + # Specific version is defined if use_version.lower() == "latest": - _print("Finding latest version") - openpype_version = openpype_versions[-1] + # Version says to use latest version + _print("Finding latest version defined by use version") + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) else: - use_version_obj = OpenPypeVersion(use_version) - # Version was specified with arguments or env OPENPYPE_VERSION - # - should crash if version is not available _print("Finding specified version \"{}\"".format(use_version)) - for version in openpype_versions: - if version == use_version_obj: - openpype_version = version - break + openpype_version = bootstrap.find_openpype_version( + use_version, use_staging + ) if openpype_version is None: raise OpenPypeVersionNotFound( @@ -700,13 +692,12 @@ def _find_frozen_openpype(use_version: str = None, ) ) - elif studio_version: + elif studio_version is not None: + # Studio has defined a version to use _print("Finding studio version \"{}\"".format(studio_version)) - # Look for version specified by settings - for version in openpype_versions: - if version == studio_version: - openpype_version = version - break + openpype_version = bootstrap.find_openpype_version( + studio_version, use_staging + ) if openpype_version is None: raise OpenPypeVersionNotFound(( "Requested OpenPype version \"{}\" defined by settings" @@ -714,13 +705,21 @@ def _find_frozen_openpype(use_version: str = None, ).format(studio_version)) else: - # Use latest available version + # Default behavior to use latest version _print("Finding latest version") - openpype_version = openpype_versions[-1] + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) + if openpype_version is None: + if use_staging: + reason = "Didn't find any staging versions." + else: + reason = "Didn't find any versions." + raise OpenPypeVersionNotFound(reason) # get local frozen version and add it to detected version so if it is # newer it will be used instead. - if local_version == openpype_version: + if build_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), From a82bf00d7a28242aa0bc134b4220a806cd108b00 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:13:00 +0100 Subject: [PATCH 071/151] removed unused imports --- openpype/settings/entities/op_version_entity.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index cf2150f12e..97b3efa028 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -1,8 +1,5 @@ from openpype.lib.openpype_version import ( - op_version_control_available, get_remote_versions, - openpype_path_is_set, - openpype_path_is_accessible, get_OpenPypeVersion, get_build_version ) From 070078d1578cb3bb177cbb53a807bd145602d084 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:13:12 +0100 Subject: [PATCH 072/151] do not add build version to staging --- openpype/settings/entities/op_version_entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 97b3efa028..bd481dc497 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -65,5 +65,4 @@ class StagingVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=True, production=False) - versions.append(get_build_version()) return sorted(versions) From 43c552dbbfd74712eeebb57e22bc1ef4b807b92e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:13:36 +0100 Subject: [PATCH 073/151] added option to pass global settings to 'get_expected_studio_version_str' --- igniter/bootstrap_repos.py | 7 +++++-- igniter/tools.py | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 77fcd15f1e..70e6a75b9d 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -591,19 +591,22 @@ class OpenPypeVersion(semver.VersionInfo): return all_versions[-1] @classmethod - def get_expected_studio_version(cls, staging=False): + def get_expected_studio_version(cls, staging=False, global_settings=None): """Expected OpenPype version that should be used at the moment. If version is not defined in settings the latest found version is used. + Using precached global settings is needed for usage inside OpenPype. + Args: staging (bool): Staging version or production version. + global_settings (dict): Optional precached global settings. Returns: OpenPypeVersion: Version that should be used. """ - result = get_expected_studio_version_str(staging) + result = get_expected_studio_version_str(staging, global_settings) if not result: return None return OpenPypeVersion(version=result) diff --git a/igniter/tools.py b/igniter/tools.py index 2595140582..735402e9a2 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -187,17 +187,21 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: return None -def get_expected_studio_version_str(staging=False) -> str: +def get_expected_studio_version_str( + staging=False, global_settings=None +) -> str: """Version that should be currently used in studio. Args: staging (bool): Get current version for staging. + global_settings (dict): Optional precached global settings. Returns: str: OpenPype version which should be used. Empty string means latest. """ mongo_url = os.environ.get("OPENPYPE_MONGO") - global_settings = get_openpype_global_settings(mongo_url) + if global_settings is None: + global_settings = get_openpype_global_settings(mongo_url) if staging: key = "staging_version" else: From ad99d2a841f4cf581f13f2ef1e9f93e95c84760e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:21:12 +0100 Subject: [PATCH 074/151] added few docstrings --- igniter/__init__.py | 4 ++++ igniter/message_dialog.py | 2 +- .../settings/entities/op_version_entity.py | 19 ++++++++++++++++++- openpype/tools/settings/settings/base.py | 1 + 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index 6f06c1eb90..02cba6a483 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -12,6 +12,9 @@ from .bootstrap_repos import ( ) from .version import __version__ as version +# Store OpenPypeVersion to 'sys.modules' +# - this makes it available in OpenPype processes without modifying +# 'sys.path' or 'PYTHONPATH' if "OpenPypeVersion" not in sys.modules: sys.modules["OpenPypeVersion"] = OpenPypeVersion @@ -64,6 +67,7 @@ def open_update_window(openpype_version): def show_message_dialog(title, message): + """Show dialog with a message and title to user.""" if os.getenv("OPENPYPE_HEADLESS_MODE"): print("!!! Can't open dialog in headless mode. Exiting.") sys.exit(1) diff --git a/igniter/message_dialog.py b/igniter/message_dialog.py index 88a086df1e..c8e875cc37 100644 --- a/igniter/message_dialog.py +++ b/igniter/message_dialog.py @@ -7,6 +7,7 @@ from .tools import ( class MessageDialog(QtWidgets.QDialog): + """Simple message dialog with title, message and OK button.""" def __init__(self, title, message): super(MessageDialog, self).__init__() @@ -27,7 +28,6 @@ class MessageDialog(QtWidgets.QDialog): btns_layout.addWidget(ok_btn, 0) layout = QtWidgets.QVBoxLayout(self) - # layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(label_widget, 1) layout.addLayout(btns_layout, 0) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index bd481dc497..62c5bf4ff9 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -12,6 +12,16 @@ from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): + """Entity to store OpenPype version to use. + + It is text input as creating of settings on different machines may + affect which versions are available so it must have option to set OpenPype + version which is not available for machine where settings entities are + loaded. + + It is possible to enter empty string. In that case is used any latest + version. Any other string must match regex of OpenPype version semantic. + """ def _item_initialization(self): super(OpenPypeVersionInput, self)._item_initialization() self.multiline = False @@ -19,9 +29,13 @@ class OpenPypeVersionInput(TextEntity): self.value_hints = [] def _get_openpype_versions(self): - return [] + """This is abstract method returning version hints for UI purposes.""" + raise NotImplementedError(( + "{} does not have implemented '_get_openpype_versions'" + ).format(self.__class__.__name__)) def set_override_state(self, state, *args, **kwargs): + """Update value hints for UI purposes.""" value_hints = [] if state is OverrideState.STUDIO: versions = self._get_openpype_versions() @@ -36,6 +50,7 @@ class OpenPypeVersionInput(TextEntity): ) def convert_to_valid_type(self, value): + """Add validation of version regex.""" if value and value is not NOT_SET: OpenPypeVersion = get_OpenPypeVersion() if OpenPypeVersion is not None: @@ -52,6 +67,7 @@ class OpenPypeVersionInput(TextEntity): class ProductionVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define production version.""" schema_types = ["production-versions-text"] def _get_openpype_versions(self): @@ -61,6 +77,7 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): class StagingVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define staging version.""" schema_types = ["staging-versions-text"] def _get_openpype_versions(self): diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index bc6c6d10ff..48c2b42ebd 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -28,6 +28,7 @@ class BaseWidget(QtWidgets.QWidget): @staticmethod def set_style_property(obj, property_name, property_value): + """Change QWidget property and polish it's style.""" if obj.property(property_name) == property_value: return From 6b5706ef78a3c3bde0a4efd2235bd46dfe8b9d81 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 10 Dec 2021 10:38:23 +0100 Subject: [PATCH 075/151] added method to get installed version --- igniter/bootstrap_repos.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 70e6a75b9d..b603fe74f1 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -522,6 +522,16 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) + @staticmethod + def get_installed_version_str() -> str: + """Get version of local OpenPype.""" + + version = {} + path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" + with open(path, "r") as fp: + exec(fp.read(), version) + return version["__version__"] + @classmethod def get_build_version(cls): """Get version of OpenPype inside build.""" From 1b4e5dd4435ae7acba7be6f21f6304a99ed9e938 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 10 Dec 2021 10:40:20 +0100 Subject: [PATCH 076/151] replaced get_local_live_version with get_installed_version_str --- igniter/bootstrap_repos.py | 12 +----------- start.py | 6 +++--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b603fe74f1..890df453a1 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -690,16 +690,6 @@ class BootstrapRepos: return v.path return None - @staticmethod - def get_local_live_version() -> str: - """Get version of local OpenPype.""" - - version = {} - path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" - with open(path, "r") as fp: - exec(fp.read(), version) - return version["__version__"] - @staticmethod def get_version(repo_dir: Path) -> Union[str, None]: """Get version of OpenPype in given directory. @@ -747,7 +737,7 @@ class BootstrapRepos: # version and use it as a source. Otherwise repo_dir is user # entered location. if not repo_dir: - version = self.get_local_live_version() + version = OpenPypeVersion.get_installed_version_str() repo_dir = self.live_repo_dir else: version = self.get_version(repo_dir) diff --git a/start.py b/start.py index 58c88df340..975249b4e2 100644 --- a/start.py +++ b/start.py @@ -794,7 +794,7 @@ def _bootstrap_from_code(use_version, use_staging): assert local_version else: # get current version of OpenPype - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() # All cases when should be used different version than build if (use_version and use_version != local_version) or use_staging: @@ -930,7 +930,7 @@ def boot(): if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) else: - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() if "validate" in commands: _print(f">>> Validating version [ {use_version} ]") @@ -978,7 +978,7 @@ def boot(): if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) else: - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() list_versions(openpype_versions, local_version) sys.exit(1) From 53e362f3db616ee95585856780b557cdcc502a25 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 10 Dec 2021 10:40:42 +0100 Subject: [PATCH 077/151] renamed build verion to installed version --- igniter/bootstrap_repos.py | 35 +++++++++++++++++------------------ start.py | 4 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 890df453a1..db62cbbe91 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -63,7 +63,7 @@ class OpenPypeVersion(semver.VersionInfo): staging = False path = None _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 - _build_version = None + _installed_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. @@ -533,17 +533,16 @@ class OpenPypeVersion(semver.VersionInfo): return version["__version__"] @classmethod - def get_build_version(cls): + def get_installed_version(cls): """Get version of OpenPype inside build.""" - if cls._build_version is None: - openpype_root = Path(os.environ["OPENPYPE_ROOT"]) - build_version_str = BootstrapRepos.get_version(openpype_root) - if build_version_str: - cls._build_version = OpenPypeVersion( - version=build_version_str, - path=openpype_root + if cls._installed_version is None: + installed_version_str = cls.get_installed_version_str() + if installed_version_str: + cls._installed_version = OpenPypeVersion( + version=installed_version_str, + path=Path(os.environ["OPENPYPE_ROOT"]) ) - return cls._build_version + return cls._installed_version @staticmethod def get_latest_version( @@ -579,7 +578,7 @@ class OpenPypeVersion(semver.VersionInfo): elif remote is None and not local: remote = True - build_version = OpenPypeVersion.get_build_version() + installed_version = OpenPypeVersion.get_installed_version() local_versions = [] remote_versions = [] if local: @@ -592,7 +591,7 @@ class OpenPypeVersion(semver.VersionInfo): ) all_versions = local_versions + remote_versions if not staging: - all_versions.append(build_version) + all_versions.append(installed_version) if not all_versions: return None @@ -1118,9 +1117,9 @@ class BootstrapRepos: if isinstance(version, str): version = OpenPypeVersion(version=version) - build_version = OpenPypeVersion.get_build_version() - if build_version == version: - return build_version + installed_version = OpenPypeVersion.get_installed_version() + if installed_version == version: + return installed_version local_versions = OpenPypeVersion.get_local_versions( staging=staging, production=not staging @@ -1146,7 +1145,7 @@ class BootstrapRepos: @staticmethod def find_latest_openpype_version(staging): - build_version = OpenPypeVersion.get_build_version() + installed_version = OpenPypeVersion.get_installed_version() local_versions = OpenPypeVersion.get_local_versions( staging=staging ) @@ -1155,14 +1154,14 @@ class BootstrapRepos: ) all_versions = local_versions + remote_versions if not staging: - all_versions.append(build_version) + all_versions.append(installed_version) if not all_versions: return None all_versions.sort() latest_version = all_versions[-1] - if latest_version == build_version: + if latest_version == installed_version: return latest_version if not latest_version.path.is_dir(): diff --git a/start.py b/start.py index 975249b4e2..5a5039cd5c 100644 --- a/start.py +++ b/start.py @@ -665,7 +665,7 @@ def _find_frozen_openpype(use_version: str = None, """ # Collect OpenPype versions - build_version = OpenPypeVersion.get_build_version() + installed_version = OpenPypeVersion.get_installed_version() # Expected version that should be used by studio settings # - this option is used only if version is not explictly set and if # studio has set explicit version in settings @@ -719,7 +719,7 @@ def _find_frozen_openpype(use_version: str = None, # get local frozen version and add it to detected version so if it is # newer it will be used instead. - if build_version == openpype_version: + if installed_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), From 3f4510a9c902b7f8d299ee5f73b2ebac8a9a232d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 17 Dec 2021 13:11:21 +0100 Subject: [PATCH 078/151] renamed 'get_build_version' to 'get_installed_version' in openpype --- openpype/lib/openpype_version.py | 6 +++--- openpype/settings/entities/op_version_entity.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 9a92454eca..e3a4e1fa3e 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -4,7 +4,7 @@ Access to logic from igniter is available only for OpenPype processes. Is meant to be able check OpenPype versions for studio. The logic is dependent on igniter's inner logic of versions. -Keep in mind that all functions except 'get_build_version' does not return +Keep in mind that all functions except 'get_installed_version' does not return OpenPype version located in build but versions available in remote versions repository or locally available. """ @@ -24,13 +24,13 @@ def op_version_control_available(): return True -def get_build_version(): +def get_installed_version(): """Get OpenPype version inside build. This version is not returned by any other functions here. """ if op_version_control_available(): - return get_OpenPypeVersion().get_build_version() + return get_OpenPypeVersion().get_installed_version() return None diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 62c5bf4ff9..6f6243cfee 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -1,7 +1,7 @@ from openpype.lib.openpype_version import ( get_remote_versions, get_OpenPypeVersion, - get_build_version + get_installed_version ) from .input_entities import TextEntity from .lib import ( @@ -72,7 +72,7 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=False, production=True) - versions.append(get_build_version()) + versions.append(get_installed_version()) return sorted(versions) From b30629866abeb5f5e86ab88dda36bdbf8f2a4cb2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:23:37 +0100 Subject: [PATCH 079/151] Improve lib.polyConstraint performance when Select tool is not the active tool context --- openpype/hosts/maya/api/lib.py | 57 +++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..af9a16b291 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -745,6 +745,33 @@ def namespaced(namespace, new=True): cmds.namespace(set=original) +@contextlib.contextmanager +def maintained_selection_api(): + """Maintain selection using the Maya Python API. + + Warning: This is *not* added to the undo stack. + + """ + original = om.MGlobal.getActiveSelectionList() + try: + yield + finally: + om.MGlobal.setActiveSelectionList(original) + + +@contextlib.contextmanager +def tool(context): + """Set a tool context during the context manager. + + """ + original = cmds.currentCtx() + try: + cmds.setToolTo(context) + yield + finally: + cmds.setToolTo(original) + + def polyConstraint(components, *args, **kwargs): """Return the list of *components* with the constraints applied. @@ -763,17 +790,25 @@ def polyConstraint(components, *args, **kwargs): kwargs.pop('mode', None) with no_undo(flush=False): - with maya.maintained_selection(): - # Apply constraint using mode=2 (current and next) so - # it applies to the selection made before it; because just - # a `maya.cmds.select()` call will not trigger the constraint. - with reset_polySelectConstraint(): - cmds.select(components, r=1, noExpand=True) - cmds.polySelectConstraint(*args, mode=2, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result + # Reverting selection to the original selection using + # `maya.cmds.select` can be slow in rare cases where previously + # `maya.cmds.polySelectConstraint` had set constrain to "All and Next" + # and the "Random" setting was activated. To work around this we + # revert to the original selection using the Maya API. This is safe + # since we're not generating any undo change anyway. + with tool("selectSuperContext"): + # Selection can be very slow when in a manipulator mode. + # So we force the selection context which is fast. + with maintained_selection_api(): + # Apply constraint using mode=2 (current and next) so + # it applies to the selection made before it; because just + # a `maya.cmds.select()` call will not trigger the constraint. + with reset_polySelectConstraint(): + cmds.select(components, r=1, noExpand=True) + return cmds.polySelectConstraint(*args, + mode=2, + returnSelection=True, + **kwargs) @contextlib.contextmanager From 86771ae01dfbdaca373cc65f05066ec86a7a48cb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:34:42 +0100 Subject: [PATCH 080/151] Optimization: Improve speed slightly more (somehow this is faster in most cases) --- openpype/hosts/maya/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index af9a16b291..bd83b13b06 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -805,10 +805,10 @@ def polyConstraint(components, *args, **kwargs): # a `maya.cmds.select()` call will not trigger the constraint. with reset_polySelectConstraint(): cmds.select(components, r=1, noExpand=True) - return cmds.polySelectConstraint(*args, - mode=2, - returnSelection=True, - **kwargs) + cmds.polySelectConstraint(*args, mode=2, **kwargs) + result = cmds.ls(selection=True) + cmds.select(clear=True) + return result @contextlib.contextmanager From 61b7b5eeee74ab17a592678bd3c411ff073e4fe3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 16:34:19 +0100 Subject: [PATCH 081/151] Fix #2449 - Remove unique name counter --- .../hosts/houdini/plugins/load/load_alembic.py | 13 +++---------- .../hosts/houdini/plugins/load/load_camera.py | 17 ++++------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index cd0f0f0d2d..df66d56008 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -1,6 +1,6 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline class AbcLoader(api.Loader): @@ -25,16 +25,9 @@ class AbcLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 + # Define node name namespace = namespace if namespace else context["asset"]["name"] - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 83246b7d97..8b98b7c05e 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' @@ -97,18 +97,9 @@ class CameraLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 - asset_name = context["asset"]["name"] - - namespace = namespace or asset_name - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node container = self.create_and_connect(obj, "alembicarchive", node_name) From bfc6ad0b655869cb00c154b0a2d62d68d8acd20b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:17:56 +0100 Subject: [PATCH 082/151] Fix #2453 Refactor missing _get_reference_node method --- openpype/hosts/maya/plugins/load/load_look.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index fca612eff4..8e14778fd2 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -8,6 +8,8 @@ from collections import defaultdict from openpype.widgets.message_window import ScrollMessageBox from Qt import QtWidgets +from openpype.hosts.maya.api.plugin import get_reference_node + class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Specific loader for lookdev""" @@ -70,7 +72,7 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, log=self.log) shader_nodes = cmds.ls(members, type='shadingEngine') orig_nodes = set(self._get_nodes_with_shader(shader_nodes)) From 1d94607037bad9393fb9ed82fae2842bbe66a626 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 16:57:42 +0100 Subject: [PATCH 083/151] Optimize validation speed for dense polymeshes (especially those that have locked normal) --- .../publish/validate_mesh_normals_unlocked.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py index b14781b608..750932df54 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py @@ -1,4 +1,5 @@ from maya import cmds +import maya.api.OpenMaya as om2 import pyblish.api import openpype.api @@ -25,10 +26,16 @@ class ValidateMeshNormalsUnlocked(pyblish.api.Validator): @staticmethod def has_locked_normals(mesh): - """Return whether a mesh node has locked normals""" - return any(cmds.polyNormalPerVertex("{}.vtxFace[*][*]".format(mesh), - query=True, - freezeNormal=True)) + """Return whether mesh has at least one locked normal""" + + sel = om2.MGlobal.getSelectionListByName(mesh) + node = sel.getDependNode(0) + fn_mesh = om2.MFnMesh(node) + _, normal_ids = fn_mesh.getNormalIds() + for normal_id in normal_ids: + if fn_mesh.isNormalLocked(normal_id): + return True + return False @classmethod def get_invalid(cls, instance): From 3a95b99e42bf3903bbd5e66e6cb2558a5e880353 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 17:56:50 +0100 Subject: [PATCH 084/151] Re-use polyConstraint from openpype.host.maya.api.lib --- .../plugins/publish/validate_mesh_ngons.py | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py index e8cc019b52..839aab0d0b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py @@ -7,21 +7,6 @@ from avalon import maya from openpype.hosts.maya.api import lib -def polyConstraint(objects, *args, **kwargs): - kwargs.pop('mode', None) - - with lib.no_undo(flush=False): - with maya.maintained_selection(): - with lib.reset_polySelectConstraint(): - cmds.select(objects, r=1, noExpand=True) - # Acting as 'polyCleanupArgList' for n-sided polygon selection - cmds.polySelectConstraint(*args, mode=3, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result - - class ValidateMeshNgons(pyblish.api.Validator): """Ensure that meshes don't have ngons @@ -41,8 +26,17 @@ class ValidateMeshNgons(pyblish.api.Validator): @staticmethod def get_invalid(instance): - meshes = cmds.ls(instance, type='mesh') - return polyConstraint(meshes, type=8, size=3) + meshes = cmds.ls(instance, type='mesh', long=True) + + # Get all faces + faces = ['{0}.f[*]'.format(node) for node in meshes] + + # Filter to n-sided polygon faces (ngons) + invalid = lib.polyConstraint(faces, + t=0x0008, # type=face + size=3) # size=nsided + + return invalid def process(self, instance): """Process all the nodes in the instance "objectSet""" From 676e68f56b2bef3dcc479cdb69175d552da15116 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 3 Jan 2022 12:41:34 +0100 Subject: [PATCH 085/151] make sure anatomy does not return unicode strings --- openpype/lib/anatomy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 66ecbd66d1..5f7285fe6c 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -1568,8 +1568,11 @@ class Roots: key_items = [self.env_prefix] for _key in keys: key_items.append(_key.upper()) + key = "_".join(key_items) - return {key: roots.value} + # Make sure key and value does not contain unicode + # - can happen in Python 2 hosts + return {str(key): str(roots.value)} output = {} for _key, _value in roots.items(): From 21b66991b7aa2418b71423a7e541c2ddf764459e Mon Sep 17 00:00:00 2001 From: BenoitConnan Date: Mon, 3 Jan 2022 15:52:17 +0100 Subject: [PATCH 086/151] add "attach_to_root" option as a qargparse argument --- openpype/hosts/maya/api/plugin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index fdad0e0989..a5f03cd576 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -100,6 +100,13 @@ class ReferenceLoader(api.Loader): "offset", label="Position Offset", help="Offset loaded models for easier selection." + ), + qargparse.Boolean( + "attach_to_root", + label="Group imported asset", + default=True, + help="Should a group be created to encapsulate" + " imported representation ?" ) ] From b8a5b1636e5bfdb1d494d63b4734b5ccbda7fa9c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 13:57:05 +0100 Subject: [PATCH 087/151] removed unused avalon_mongo_url from module --- openpype/modules/avalon_apps/avalon_app.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index 9e650a097e..347fcf11a6 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -13,14 +13,6 @@ class AvalonModule(OpenPypeModule, ITrayModule): avalon_settings = modules_settings[self.name] - # Check if environment is already set - avalon_mongo_url = os.environ.get("AVALON_MONGO") - if not avalon_mongo_url: - avalon_mongo_url = avalon_settings["AVALON_MONGO"] - # Use pype mongo if Avalon's mongo not defined - if not avalon_mongo_url: - avalon_mongo_url = os.environ["OPENPYPE_MONGO"] - thumbnail_root = os.environ.get("AVALON_THUMBNAIL_ROOT") if not thumbnail_root: thumbnail_root = avalon_settings["AVALON_THUMBNAIL_ROOT"] @@ -31,7 +23,6 @@ class AvalonModule(OpenPypeModule, ITrayModule): avalon_mongo_timeout = avalon_settings["AVALON_TIMEOUT"] self.thumbnail_root = thumbnail_root - self.avalon_mongo_url = avalon_mongo_url self.avalon_mongo_timeout = avalon_mongo_timeout # Tray attributes From 4a2b1865bf6f11e1255d6cf0d19e67b0fe3dc773 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 13:57:39 +0100 Subject: [PATCH 088/151] library loader is not always on top in tray --- openpype/modules/avalon_apps/avalon_app.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index 347fcf11a6..51a22323f1 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -42,12 +42,20 @@ class AvalonModule(OpenPypeModule, ITrayModule): def tray_init(self): # Add library tool try: + from Qt import QtCore from openpype.tools.libraryloader import LibraryLoaderWindow - self.libraryloader = LibraryLoaderWindow( + libraryloader = LibraryLoaderWindow( show_projects=True, show_libraries=True ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self.libraryloader = libraryloader + except Exception: self.log.warning( "Couldn't load Library loader tool for tray.", From 80fd24fed5ad4ffcd99a6de8495b78efce6fc85d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 13:57:55 +0100 Subject: [PATCH 089/151] use openpype style in delivery loader --- openpype/plugins/load/delivery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index a8cb0070ee..90f8e91571 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -3,11 +3,11 @@ import copy from Qt import QtWidgets, QtCore, QtGui -from avalon import api, style +from avalon import api from avalon.api import AvalonMongoDB from openpype.api import Anatomy, config -from openpype import resources +from openpype import resources, style from openpype.lib.delivery import ( sizeof_fmt, From d53b038b4abf928f07527139043143b2b831eaef Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 13:58:14 +0100 Subject: [PATCH 090/151] modified flags of delivery dialog to be always on top --- openpype/plugins/load/delivery.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index 90f8e91571..1037d6dc16 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -1,5 +1,5 @@ -from collections import defaultdict import copy +from collections import defaultdict from Qt import QtWidgets, QtCore, QtGui @@ -58,6 +58,18 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): def __init__(self, contexts, log=None, parent=None): super(DeliveryOptionsDialog, self).__init__(parent=parent) + self.setWindowTitle("OpenPype - Deliver versions") + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + + self.setStyleSheet(style.load_stylesheet()) + project = contexts[0]["project"]["name"] self.anatomy = Anatomy(project) self._representations = None @@ -70,16 +82,6 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self._set_representations(contexts) - self.setWindowTitle("OpenPype - Deliver versions") - icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) - self.setWindowIcon(icon) - - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint - ) - self.setStyleSheet(style.load_stylesheet()) - dropdown = QtWidgets.QComboBox() self.templates = self._get_templates(self.anatomy) for name, _ in self.templates.items(): From cd0ad34595286cd06fd48c970713f4beba0d8a86 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 4 Jan 2022 16:34:10 +0100 Subject: [PATCH 091/151] modify comment Co-authored-by: Petr Kalis --- openpype/settings/entities/op_version_entity.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 6f6243cfee..55f8450edf 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -14,10 +14,8 @@ from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): """Entity to store OpenPype version to use. - It is text input as creating of settings on different machines may - affect which versions are available so it must have option to set OpenPype - version which is not available for machine where settings entities are - loaded. + Settings created on another machine may affect available versions on current user's machine. + Text input element is provided to explicitly set version not yet showing up the user's machine. It is possible to enter empty string. In that case is used any latest version. Any other string must match regex of OpenPype version semantic. From 18104b6aed36ee490992e76fe11b0b02b8d7ef1f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 16:54:48 +0100 Subject: [PATCH 092/151] fix docstring text length --- openpype/settings/entities/op_version_entity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 55f8450edf..1576173654 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -14,8 +14,9 @@ from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): """Entity to store OpenPype version to use. - Settings created on another machine may affect available versions on current user's machine. - Text input element is provided to explicitly set version not yet showing up the user's machine. + Settings created on another machine may affect available versions + on current user's machine. Text input element is provided to explicitly + set version not yet showing up the user's machine. It is possible to enter empty string. In that case is used any latest version. Any other string must match regex of OpenPype version semantic. From 07e151981c34e8cdb5710ce8a853be08530f0cb9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:00 +0100 Subject: [PATCH 093/151] skip duplicated versions --- openpype/settings/entities/op_version_entity.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 1576173654..6184f7640a 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -38,9 +38,10 @@ class OpenPypeVersionInput(TextEntity): value_hints = [] if state is OverrideState.STUDIO: versions = self._get_openpype_versions() - if versions is not None: - for version in versions: - value_hints.append(str(version)) + for version in versions: + version_str = str(version) + if version_str not in value_hints: + value_hints.append(version_str) self.value_hints = value_hints From ef87a1f086adf20d57f9284b33253f7b04d38b75 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:43 +0100 Subject: [PATCH 094/151] confirmation dialog requires to write whole project name --- .../project_manager/project_manager/widgets.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index e4c58a8a2c..392f3f4503 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -336,18 +336,21 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message = ( + message_label = QtWidgets.QLabel(self) + message_label.setWordWrap(True) + message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + message_label.setText(( "Project \"{}\" with all related data will be" " permanently removed from the database (This actions won't remove" " any files on disk)." - ).format(project_name) - message_label = QtWidgets.QLabel(message, self) - message_label.setWordWrap(True) + ).format(project_name)) question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) - confirm_input.setPlaceholderText("Type \"Delete\" to confirm...") + confirm_input.setPlaceholderText( + "Type \"{}\" to confirm...".format(project_name) + ) cancel_btn = _SameSizeBtns("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") @@ -379,6 +382,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._confirm_btn = confirm_btn self._confirm_input = confirm_input self._result = 0 + self._project_name = project_name self.setMinimumWidth(480) self.setMaximumWidth(650) @@ -411,5 +415,5 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._on_confirm_click() def _on_confirm_text_change(self): - enabled = self._confirm_input.text().lower() == "delete" + enabled = self._confirm_input.text() == self._project_name self._confirm_btn.setEnabled(enabled) From de1c043ec8819b7fd68cebc5152ea8ad60d3e754 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:59 +0100 Subject: [PATCH 095/151] added label "Delete project" to delete button --- openpype/tools/project_manager/project_manager/window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index a05811e813..0298d565a5 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -78,7 +78,9 @@ class ProjectManagerWindow(QtWidgets.QWidget): ) create_folders_btn.setEnabled(False) - remove_projects_btn = QtWidgets.QPushButton(project_widget) + remove_projects_btn = QtWidgets.QPushButton( + "Delete project", project_widget + ) remove_projects_btn.setIcon(ResourceCache.get_icon("remove")) remove_projects_btn.setObjectName("IconBtn") From 571eef7cb374f87c62ed8d9be804994dde43624e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 18:24:50 +0100 Subject: [PATCH 096/151] fix filtering criteria for profile selection --- openpype/lib/avalon_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index cb5bca133d..8180e416a9 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1560,7 +1560,7 @@ def get_custom_workfile_template_by_context( # get path from matching profile matching_item = filter_profiles( template_profiles, - {"task_type": current_task_type} + {"task_types": current_task_type} ) # when path is available try to format it in case # there are some anatomy template strings From eaeb96c92f4075f2719892d732ef842eb5f36fb3 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 5 Jan 2022 03:41:51 +0000 Subject: [PATCH 097/151] [Automated] Bump version --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc14b5f507..c46b1f37e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,34 @@ # Changelog +## [3.8.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) + +**🆕 New features** + +- Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) + +**🚀 Enhancements** + +- Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) +- Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) +- Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) +- Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) +- Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) +- Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) + +**🐛 Bug fixes** + +- General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) +- Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) + ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.4...3.7.0) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.14...3.7.0) + +**Deprecated:** + +- General: Default modules hierarchy n2 [\#2368](https://github.com/pypeclub/OpenPype/pull/2368) **🚀 Enhancements** diff --git a/openpype/version.py b/openpype/version.py index 8fac77bcdf..4f10deeae1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.7.0" +__version__ = "3.8.0-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index dd1f5c90b6..20aaf62d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.7.0" # OpenPype +version = "3.8.0-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 6b8b7f22e65aa964f0f9276672adcbabae8c392e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 10:30:31 +0100 Subject: [PATCH 098/151] Fix #2473 Update "Repair Context" label to "Repair" --- openpype/action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/action.py b/openpype/action.py index 3fc6dd1a8f..50741875e4 100644 --- a/openpype/action.py +++ b/openpype/action.py @@ -72,7 +72,7 @@ class RepairContextAction(pyblish.api.Action): is available on the plugin. """ - label = "Repair Context" + label = "Repair" on = "failed" # This action is only available on a failed plug-in def process(self, context, plugin): From 65053ef948fc712ae939fc2756a0dc0abfed0643 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 15:33:15 +0100 Subject: [PATCH 099/151] trigger on task changed in set context method --- openpype/tools/workfiles/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 0615ec0aca..f4a86050cb 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -1074,6 +1074,7 @@ class Window(QtWidgets.QMainWindow): if "task" in context: self.tasks_widget.select_task_name(context["task"]) + self._on_task_changed() def _on_asset_changed(self): asset_id = self.assets_widget.get_selected_asset_id() From d84d334ac6140a197c10ae88d6057d31bd8122e3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 16:15:22 +0100 Subject: [PATCH 100/151] fix style and dialog modality to parent widget --- openpype/tools/loader/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index ea45fd4364..f5ade04ae9 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -535,7 +535,7 @@ class SubsetWidget(QtWidgets.QWidget): self.load_ended.emit() if error_info: - box = LoadErrorMessageBox(error_info) + box = LoadErrorMessageBox(error_info, self) box.show() def selected_subsets(self, _groups=False, _merged=False, _other=True): @@ -1431,7 +1431,7 @@ class RepresentationWidget(QtWidgets.QWidget): self.load_ended.emit() if errors: - box = LoadErrorMessageBox(errors) + box = LoadErrorMessageBox(errors, self) box.show() def _get_optional_labels(self, loaders, selected_side): From 9d39d05e6bed847055a02a7a5f8686dcfa056545 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:44:16 +0100 Subject: [PATCH 101/151] styl have function to return path to images in resources --- openpype/style/__init__.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index cb0595d522..ea88b342ee 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -1,8 +1,10 @@ import os import json import collections -from openpype import resources import six + +from openpype import resources + from .color_defs import parse_color @@ -12,6 +14,18 @@ _FONT_IDS = None current_dir = os.path.dirname(os.path.abspath(__file__)) +def get_style_image_path(image_name): + # All filenames are lowered + image_name = image_name.lower() + # Male sure filename has png extension + if not image_name.endswith(".png"): + image_name += ".png" + filepath = os.path.join(current_dir, "images", image_name) + if os.path.exists(filepath): + return filepath + return None + + def _get_colors_raw_data(): """Read data file with stylesheet fill values. @@ -160,6 +174,11 @@ def load_stylesheet(): return _STYLESHEET_CACHE -def app_icon_path(): +def get_app_icon_path(): """Path to OpenPype icon.""" return resources.get_openpype_icon_filepath() + + +def app_icon_path(): + # Backwards compatibility + return get_app_icon_path() From 13aa752c40fdfd1f61412eaea455930101b64045 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:47:32 +0100 Subject: [PATCH 102/151] added clickable frame to utils --- openpype/tools/utils/__init__.py | 4 ++++ openpype/tools/utils/widgets.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 7f15e64767..2a6d62453b 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -1,8 +1,12 @@ from .widgets import ( PlaceholderLineEdit, + BaseClickableFrame, + ClickableFrame, ) __all__ = ( "PlaceholderLineEdit", + "BaseClickableFrame", + "ClickableFrame", ) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 3bfa092a21..3e45600e33 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -25,6 +25,41 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class BaseClickableFrame(QtWidgets.QFrame): + """Widget that catch left mouse click and can trigger a callback. + + Callback is defined by overriding `_mouse_release_callback`. + """ + def __init__(self, parent): + super(BaseClickableFrame, self).__init__(parent) + + self._mouse_pressed = False + + def _mouse_release_callback(self): + pass + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(BaseClickableFrame, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self._mouse_release_callback() + + super(BaseClickableFrame, self).mouseReleaseEvent(event) + + +class ClickableFrame(BaseClickableFrame): + """Extended clickable frame which triggers 'clicked' signal.""" + clicked = QtCore.Signal() + + def _mouse_release_callback(self): + self.clicked.emit() + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font. From b2f09495c49fda626a30ef6c1fd21d8918b9ea45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:47:54 +0100 Subject: [PATCH 103/151] implemented expand button showing icon and having clicked signal --- openpype/tools/utils/__init__.py | 2 + openpype/tools/utils/widgets.py | 64 +++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 2a6d62453b..294b919b5c 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -2,6 +2,7 @@ from .widgets import ( PlaceholderLineEdit, BaseClickableFrame, ClickableFrame, + ExpandBtn, ) @@ -9,4 +10,5 @@ __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", + "ExpandBtn" ) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 3e45600e33..c32eae043e 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -3,7 +3,10 @@ import logging from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome, qargparse -from openpype.style import get_objected_colors +from openpype.style import ( + get_objected_colors, + get_style_image_path +) log = logging.getLogger(__name__) @@ -60,6 +63,65 @@ class ClickableFrame(BaseClickableFrame): self.clicked.emit() +class ExpandBtnLabel(QtWidgets.QLabel): + """Label showing expand icon meant for ExpandBtn.""" + def __init__(self, parent): + super(ExpandBtnLabel, self).__init__(parent) + self._source_collapsed_pix = QtGui.QPixmap( + get_style_image_path("branch_closed") + ) + self._source_expanded_pix = QtGui.QPixmap( + get_style_image_path("branch_open") + ) + + self._current_image = self._source_collapsed_pix + self._collapsed = True + + def set_collapsed(self, collapsed): + if self._collapsed == collapsed: + return + self._collapsed = collapsed + if collapsed: + self._current_image = self._source_collapsed_pix + else: + self._current_image = self._source_expanded_pix + self._set_resized_pix() + + def resizeEvent(self, event): + self._set_resized_pix() + super(ExpandBtnLabel, self).resizeEvent(event) + + def _set_resized_pix(self): + size = int(self.fontMetrics().height() / 2) + if size < 1: + size = 1 + size += size % 2 + self.setPixmap( + self._current_image.scaled( + size, + size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) + + +class ExpandBtn(ClickableFrame): + def __init__(self, parent=None): + super(ExpandBtn, self).__init__(parent) + + pixmap_label = ExpandBtnLabel(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(pixmap_label) + + self._pixmap_label = pixmap_label + + def set_collapsed(self, collapsed): + self._pixmap_label.set_collapsed(collapsed) + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font. From 8ef9f44854bdd1e2b6001148eb4714d41dc9fee6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:49:11 +0100 Subject: [PATCH 104/151] implemented traceback widget showin traceback in loader --- openpype/tools/loader/widgets.py | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index f5ade04ae9..c8e371ee6f 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -18,6 +18,8 @@ from openpype.tools.utils.delegates import ( ) from openpype.tools.utils.widgets import ( OptionalMenu, + ClickableFrame, + ExpandBtn, PlaceholderLineEdit ) from openpype.tools.utils.views import ( @@ -64,6 +66,55 @@ class OverlayFrame(QtWidgets.QFrame): self.label_widget.setText(label) +class TracebackWidget(QtWidgets.QWidget): + def __init__(self, tb_text, parent): + super(TracebackWidget, self).__init__(parent) + + # Modify text to match html + # - add more replacements when needed + tb_text = ( + tb_text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + expand_btn = ExpandBtn(self) + + clickable_frame = ClickableFrame(self) + clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) + clickable_layout.setContentsMargins(0, 0, 0, 0) + + expand_label = QtWidgets.QLabel("Details", clickable_frame) + clickable_layout.addWidget(expand_label, 0) + clickable_layout.addStretch(1) + + show_details_layout = QtWidgets.QHBoxLayout() + show_details_layout.addWidget(expand_btn, 0) + show_details_layout.addWidget(clickable_frame, 1) + + text_widget = QtWidgets.QLabel(self) + text_widget.setText(tb_text) + text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + text_widget.setVisible(False) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(show_details_layout, 0) + layout.addWidget(text_widget, 1) + + clickable_frame.clicked.connect(self._on_show_details_click) + expand_btn.clicked.connect(self._on_show_details_click) + + self._expand_btn = expand_btn + self._text_widget = text_widget + + def _on_show_details_click(self): + self._text_widget.setVisible(not self._text_widget.isVisible()) + self._expand_btn.set_collapsed(not self._text_widget.isVisible()) + + class LoadErrorMessageBox(QtWidgets.QDialog): def __init__(self, messages, parent=None): super(LoadErrorMessageBox, self).__init__(parent) From 72503a16e70c57b8ada5404a88f513afaa2f9161 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:50:14 +0100 Subject: [PATCH 105/151] reorganized contetn of error dialog --- openpype/tools/loader/widgets.py | 62 ++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index c8e371ee6f..80c4bade08 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -121,13 +121,10 @@ class LoadErrorMessageBox(QtWidgets.QDialog): self.setWindowTitle("Loading failed") self.setFocusPolicy(QtCore.Qt.StrongFocus) - body_layout = QtWidgets.QVBoxLayout(self) - main_label = ( "Failed to load items" ) main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) item_name_template = ( "Subset: {}
" @@ -136,38 +133,57 @@ class LoadErrorMessageBox(QtWidgets.QDialog): ) exc_msg_template = "{}" - for exc_msg, tb, repre, subset, version in messages: + content_scroll = QtWidgets.QScrollArea(self) + content_scroll.setWidgetResizable(True) + + content_widget = QtWidgets.QWidget(content_scroll) + content_scroll.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + for exc_msg, tb_text, repre, subset, version in messages: line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) item_name = item_name_template.format(subset, version, repre) item_name_widget = QtWidgets.QLabel( item_name.replace("\n", "
"), self ) - body_layout.addWidget(item_name_widget) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) - if tb: - tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) + if tb_text: + line = self._create_line() + tb_widget = TracebackWidget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - buttonBox.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok - ) - buttonBox.accepted.connect(self._on_accept) - footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) + content_layout.addStretch(1) - def _on_accept(self): + ok_btn = QtWidgets.QPushButton("OK", self) + + footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn, 0) + + bottom_line = self._create_line() + body_layout = QtWidgets.QVBoxLayout(self) + body_layout.addWidget(main_label_widget, 0) + body_layout.addWidget(content_scroll, 1) + body_layout.addWidget(bottom_line, 0) + body_layout.addLayout(footer_layout, 0) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self.resize(660, 350) + + def _on_ok_clicked(self): self.close() def _create_line(self): From 631ba5b12e35f669113b55dcd8d2a45136f42088 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:50:50 +0100 Subject: [PATCH 106/151] line has different color --- openpype/tools/loader/widgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 80c4bade08..0e392aef86 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -188,9 +188,9 @@ class LoadErrorMessageBox(QtWidgets.QDialog): def _create_line(self): line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) + line.setObjectName("Separator") + line.setMinimumHeight(2) + line.setMaximumHeight(2) return line From 049d0d27f9152c3b690a6f5546ec02547bb89faf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:51:00 +0100 Subject: [PATCH 107/151] added ability to copy report --- openpype/tools/loader/widgets.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 0e392aef86..a39ac7213a 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -142,7 +142,22 @@ class LoadErrorMessageBox(QtWidgets.QDialog): content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) + report_data = [] for exc_msg, tb_text, repre, subset, version in messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + line = self._create_line() content_layout.addWidget(line) @@ -166,9 +181,11 @@ class LoadErrorMessageBox(QtWidgets.QDialog): content_layout.addStretch(1) + copy_report_btn = QtWidgets.QPushButton("Copy report", self) ok_btn = QtWidgets.QPushButton("OK", self) footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addWidget(copy_report_btn, 0) footer_layout.addStretch(1) footer_layout.addWidget(ok_btn, 0) @@ -179,13 +196,25 @@ class LoadErrorMessageBox(QtWidgets.QDialog): body_layout.addWidget(bottom_line, 0) body_layout.addLayout(footer_layout, 0) + copy_report_btn.clicked.connect(self._on_copy_report) ok_btn.clicked.connect(self._on_ok_clicked) self.resize(660, 350) + self._report_data = report_data + def _on_ok_clicked(self): self.close() + def _on_copy_report(self): + report_text = (10 * "*").join(self._report_data) + + mime_data = QtCore.QMimeData() + mime_data.setText(report_text) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + def _create_line(self): line = QtWidgets.QFrame(self) line.setObjectName("Separator") From 7d72481b865fcdb78e8cd26c924f37a997533423 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:31:08 +0100 Subject: [PATCH 108/151] create base of error dialog in utils --- openpype/tools/loader/widgets.py | 159 ++++++--------------------- openpype/tools/utils/__init__.py | 6 +- openpype/tools/utils/error_dialog.py | 143 ++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 128 deletions(-) create mode 100644 openpype/tools/utils/error_dialog.py diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index a39ac7213a..3accaed5ab 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -11,15 +11,16 @@ from Qt import QtWidgets, QtCore, QtGui from avalon import api, pipeline from avalon.lib import HeroVersionType -from openpype.tools.utils import lib as tools_lib +from openpype.tools.utils import ( + ErrorMessageBox, + lib as tools_lib +) from openpype.tools.utils.delegates import ( VersionDelegate, PrettyTimeDelegate ) from openpype.tools.utils.widgets import ( OptionalMenu, - ClickableFrame, - ExpandBtn, PlaceholderLineEdit ) from openpype.tools.utils.views import ( @@ -66,66 +67,12 @@ class OverlayFrame(QtWidgets.QFrame): self.label_widget.setText(label) -class TracebackWidget(QtWidgets.QWidget): - def __init__(self, tb_text, parent): - super(TracebackWidget, self).__init__(parent) - - # Modify text to match html - # - add more replacements when needed - tb_text = ( - tb_text - .replace("<", "<") - .replace(">", ">") - .replace("\n", "
") - .replace(" ", " ") - ) - - expand_btn = ExpandBtn(self) - - clickable_frame = ClickableFrame(self) - clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) - clickable_layout.setContentsMargins(0, 0, 0, 0) - - expand_label = QtWidgets.QLabel("Details", clickable_frame) - clickable_layout.addWidget(expand_label, 0) - clickable_layout.addStretch(1) - - show_details_layout = QtWidgets.QHBoxLayout() - show_details_layout.addWidget(expand_btn, 0) - show_details_layout.addWidget(clickable_frame, 1) - - text_widget = QtWidgets.QLabel(self) - text_widget.setText(tb_text) - text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - text_widget.setVisible(False) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addLayout(show_details_layout, 0) - layout.addWidget(text_widget, 1) - - clickable_frame.clicked.connect(self._on_show_details_click) - expand_btn.clicked.connect(self._on_show_details_click) - - self._expand_btn = expand_btn - self._text_widget = text_widget - - def _on_show_details_click(self): - self._text_widget.setVisible(not self._text_widget.isVisible()) - self._expand_btn.set_collapsed(not self._text_widget.isVisible()) - - -class LoadErrorMessageBox(QtWidgets.QDialog): +class LoadErrorMessageBox(ErrorMessageBox): def __init__(self, messages, parent=None): - super(LoadErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Loading failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - main_label = ( - "Failed to load items" - ) - main_label_widget = QtWidgets.QLabel(main_label, self) + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + def _create_content(self, content_layout): item_name_template = ( "Subset: {}
" "Version: {}
" @@ -133,31 +80,7 @@ class LoadErrorMessageBox(QtWidgets.QDialog): ) exc_msg_template = "{}" - content_scroll = QtWidgets.QScrollArea(self) - content_scroll.setWidgetResizable(True) - - content_widget = QtWidgets.QWidget(content_scroll) - content_scroll.setWidget(content_widget) - - content_layout = QtWidgets.QVBoxLayout(content_widget) - content_layout.setContentsMargins(0, 0, 0, 0) - - report_data = [] - for exc_msg, tb_text, repre, subset, version in messages: - report_message = ( - "During load error happened on Subset: \"{subset}\"" - " Representation: \"{repre}\" Version: {version}" - "\n\nError message: {message}" - ).format( - subset=subset, - repre=repre, - version=version, - message=exc_msg - ) - if tb_text: - report_message += "\n\n{}".format(tb_text) - report_data.append(report_message) - + for exc_msg, tb_text, repre, subset, version in self._messages: line = self._create_line() content_layout.addWidget(line) @@ -175,52 +98,34 @@ class LoadErrorMessageBox(QtWidgets.QDialog): if tb_text: line = self._create_line() - tb_widget = TracebackWidget(tb_text, self) + tb_widget = self._create_traceback_widget(tb_text, self) content_layout.addWidget(line) content_layout.addWidget(tb_widget) - content_layout.addStretch(1) + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, subset, version in self._messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data - copy_report_btn = QtWidgets.QPushButton("Copy report", self) - ok_btn = QtWidgets.QPushButton("OK", self) - - footer_layout = QtWidgets.QHBoxLayout() - footer_layout.addWidget(copy_report_btn, 0) - footer_layout.addStretch(1) - footer_layout.addWidget(ok_btn, 0) - - bottom_line = self._create_line() - body_layout = QtWidgets.QVBoxLayout(self) - body_layout.addWidget(main_label_widget, 0) - body_layout.addWidget(content_scroll, 1) - body_layout.addWidget(bottom_line, 0) - body_layout.addLayout(footer_layout, 0) - - copy_report_btn.clicked.connect(self._on_copy_report) - ok_btn.clicked.connect(self._on_ok_clicked) - - self.resize(660, 350) - - self._report_data = report_data - - def _on_ok_clicked(self): - self.close() - - def _on_copy_report(self): - report_text = (10 * "*").join(self._report_data) - - mime_data = QtCore.QMimeData() - mime_data.setText(report_text) - QtWidgets.QApplication.instance().clipboard().setMimeData( - mime_data + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" ) - - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setObjectName("Separator") - line.setMinimumHeight(2) - line.setMaximumHeight(2) - return line + return label_widget class SubsetWidget(QtWidgets.QWidget): diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 294b919b5c..4dd6bdd05f 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -5,10 +5,14 @@ from .widgets import ( ExpandBtn, ) +from .error_dialog import ErrorMessageBox + __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", - "ExpandBtn" + "ExpandBtn", + + "ErrorMessageBox" ) diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py new file mode 100644 index 0000000000..2f39ccf139 --- /dev/null +++ b/openpype/tools/utils/error_dialog.py @@ -0,0 +1,143 @@ +from Qt import QtWidgets, QtCore + +from .widgets import ClickableFrame, ExpandBtn + + +class TracebackWidget(QtWidgets.QWidget): + def __init__(self, tb_text, parent): + super(TracebackWidget, self).__init__(parent) + + # Modify text to match html + # - add more replacements when needed + tb_text = ( + tb_text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + expand_btn = ExpandBtn(self) + + clickable_frame = ClickableFrame(self) + clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) + clickable_layout.setContentsMargins(0, 0, 0, 0) + + expand_label = QtWidgets.QLabel("Details", clickable_frame) + clickable_layout.addWidget(expand_label, 0) + clickable_layout.addStretch(1) + + show_details_layout = QtWidgets.QHBoxLayout() + show_details_layout.addWidget(expand_btn, 0) + show_details_layout.addWidget(clickable_frame, 1) + + text_widget = QtWidgets.QLabel(self) + text_widget.setText(tb_text) + text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + text_widget.setVisible(False) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(show_details_layout, 0) + layout.addWidget(text_widget, 1) + + clickable_frame.clicked.connect(self._on_show_details_click) + expand_btn.clicked.connect(self._on_show_details_click) + + self._expand_btn = expand_btn + self._text_widget = text_widget + + def _on_show_details_click(self): + self._text_widget.setVisible(not self._text_widget.isVisible()) + self._expand_btn.set_collapsed(not self._text_widget.isVisible()) + + +class ErrorMessageBox(QtWidgets.QDialog): + _default_width = 660 + _default_height = 350 + + def __init__(self, title, parent): + super(ErrorMessageBox, self).__init__(parent) + self.setWindowTitle(title) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + top_widget = self._create_top_widget(self) + + content_scroll = QtWidgets.QScrollArea(self) + content_scroll.setWidgetResizable(True) + + content_widget = QtWidgets.QWidget(content_scroll) + content_scroll.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + self._create_content(content_layout) + + content_layout.addStretch(1) + + copy_report_btn = QtWidgets.QPushButton("Copy report", self) + ok_btn = QtWidgets.QPushButton("OK", self) + + footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addWidget(copy_report_btn, 0) + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn, 0) + + bottom_line = self._create_line() + body_layout = QtWidgets.QVBoxLayout(self) + body_layout.addWidget(top_widget, 0) + body_layout.addWidget(content_scroll, 1) + body_layout.addWidget(bottom_line, 0) + body_layout.addLayout(footer_layout, 0) + + copy_report_btn.clicked.connect(self._on_copy_report) + ok_btn.clicked.connect(self._on_ok_clicked) + + self.resize(self._default_width, self._default_height) + + report_data = self._get_report_data() + if not report_data: + copy_report_btn.setVisible(False) + + self._report_data = report_data + self._content_widget = content_widget + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Something went wrong" + ) + return label_widget + + def _create_content(self, content_layout): + raise NotImplementedError( + "Method '_fill_content_layout' is not implemented!" + ) + + def _get_report_data(self): + return [] + + def _on_ok_clicked(self): + self.close() + + def _on_copy_report(self): + report_text = (10 * "*").join(self._report_data) + + mime_data = QtCore.QMimeData() + mime_data.setText(report_text) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setObjectName("Separator") + line.setMinimumHeight(2) + line.setMaximumHeight(2) + return line + + def _create_traceback_widget(self, traceback_text, parent=None): + if parent is None: + parent = self._content_widget + return TracebackWidget(traceback_text, parent) From 7107e797f0f0dfb0b5106062bcc3060cf3424de7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:42:18 +0100 Subject: [PATCH 109/151] extracted function for preparation of text with html symbols --- openpype/tools/loader/widgets.py | 50 ++++++++++++++-------------- openpype/tools/utils/error_dialog.py | 23 ++++++++----- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 3accaed5ab..ed130f765c 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -72,6 +72,31 @@ class LoadErrorMessageBox(ErrorMessageBox): self._messages = messages super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" + ) + return label_widget + + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, subset, version in self._messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + def _create_content(self, content_layout): item_name_template = ( "Subset: {}
" @@ -102,31 +127,6 @@ class LoadErrorMessageBox(ErrorMessageBox): content_layout.addWidget(line) content_layout.addWidget(tb_widget) - def _get_report_data(self): - report_data = [] - for exc_msg, tb_text, repre, subset, version in self._messages: - report_message = ( - "During load error happened on Subset: \"{subset}\"" - " Representation: \"{repre}\" Version: {version}" - "\n\nError message: {message}" - ).format( - subset=subset, - repre=repre, - version=version, - message=exc_msg - ) - if tb_text: - report_message += "\n\n{}".format(tb_text) - report_data.append(report_message) - return report_data - - def _create_top_widget(self, parent_widget): - label_widget = QtWidgets.QLabel(parent_widget) - label_widget.setText( - "Failed to load items" - ) - return label_widget - class SubsetWidget(QtWidgets.QWidget): """A widget that lists the published subsets for an asset""" diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py index 2f39ccf139..0336f4bb08 100644 --- a/openpype/tools/utils/error_dialog.py +++ b/openpype/tools/utils/error_dialog.py @@ -3,20 +3,23 @@ from Qt import QtWidgets, QtCore from .widgets import ClickableFrame, ExpandBtn +def convert_text_for_html(text): + return ( + text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + class TracebackWidget(QtWidgets.QWidget): def __init__(self, tb_text, parent): super(TracebackWidget, self).__init__(parent) # Modify text to match html # - add more replacements when needed - tb_text = ( - tb_text - .replace("<", "<") - .replace(">", ">") - .replace("\n", "
") - .replace(" ", " ") - ) - + tb_text = convert_text_for_html(tb_text) expand_btn = ExpandBtn(self) clickable_frame = ClickableFrame(self) @@ -103,6 +106,10 @@ class ErrorMessageBox(QtWidgets.QDialog): self._report_data = report_data self._content_widget = content_widget + @staticmethod + def convert_text_for_html(text): + return convert_text_for_html(text) + def _create_top_widget(self, parent_widget): label_widget = QtWidgets.QLabel(parent_widget) label_widget.setText( From acca8849a273daefe4b58567060edc4433ca96d2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:43:01 +0100 Subject: [PATCH 110/151] pass parent to creator error dialog --- openpype/tools/creator/window.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index dca1735121..22a6d5ce9c 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -445,7 +445,11 @@ class CreatorWindow(QtWidgets.QDialog): if error_info: box = CreateErrorMessageBox( - creator_plugin.family, subset_name, asset_name, *error_info + creator_plugin.family, + subset_name, + asset_name, + *error_info, + parent=self ) box.show() # Store dialog so is not garbage collected before is shown From 6cc944d46ca0cded5f6324f680fe1a4835279aef Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:43:15 +0100 Subject: [PATCH 111/151] use ErrorMessageBox in creator too --- openpype/tools/creator/widgets.py | 99 +++++++++++++++---------------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py index 89c90cc048..d2258a31c5 100644 --- a/openpype/tools/creator/widgets.py +++ b/openpype/tools/creator/widgets.py @@ -7,9 +7,10 @@ from avalon.vendor import qtawesome from openpype import style from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS +from openpype.tools.utils import ErrorMessageBox -class CreateErrorMessageBox(QtWidgets.QDialog): +class CreateErrorMessageBox(ErrorMessageBox): def __init__( self, family, @@ -17,23 +18,38 @@ class CreateErrorMessageBox(QtWidgets.QDialog): asset_name, exc_msg, formatted_traceback, - parent=None + parent ): - super(CreateErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Creation failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) + self._family = family + self._subset_name = subset_name + self._asset_name = asset_name + self._exc_msg = exc_msg + self._formatted_traceback = formatted_traceback + super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - body_layout = QtWidgets.QVBoxLayout(self) - - main_label = ( + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( "Failed to create" ) - main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) + return label_widget + def _get_report_data(self): + report_message = ( + "Failed to create Subset: \"{subset}\" Family: \"{family}\"" + " in Asset: \"{asset}\"" + "\n\nError: {message}" + ).format( + subset=self._subset_name, + family=self._family, + asset=self._asset, + message=self._exc_msg + ) + if self._formatted_traceback: + report_message += "\n\n{}".format(self._formatted_traceback) + return [report_message] + + def _create_content(self, content_layout): item_name_template = ( "Family: {}
" "Subset: {}
" @@ -42,50 +58,29 @@ class CreateErrorMessageBox(QtWidgets.QDialog): exc_msg_template = "{}" line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) - item_name = item_name_template.format(family, subset_name, asset_name) - item_name_widget = QtWidgets.QLabel( - item_name.replace("\n", "
"), self - ) - body_layout.addWidget(item_name_widget) - - exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) - message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) - - if formatted_traceback: - tb_widget = QtWidgets.QLabel( - formatted_traceback.replace("\n", "
"), self + item_name_widget = QtWidgets.QLabel(self) + item_name_widget.setText( + self.convert_text_for_html( + item_name_template.format( + self._family, self._subset_name, self._asset_name + ) ) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) - - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - button_box.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok ) - button_box.accepted.connect(self._on_accept) - footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) + content_layout.addWidget(item_name_widget) - def showEvent(self, event): - self.setStyleSheet(style.load_stylesheet()) - super(CreateErrorMessageBox, self).showEvent(event) + message_label_widget = QtWidgets.QLabel(self) + message_label_widget.setText( + exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) + ) + content_layout.addWidget(message_label_widget) - def _on_accept(self): - self.close() - - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line + if self._formatted_traceback: + tb_widget = self._create_traceback_widget( + self._formatted_traceback + ) + content_layout.addWidget(tb_widget) class SubsetNameValidator(QtGui.QRegExpValidator): From e410addc46159f8f0c547d761890173548866dc5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:50:09 +0100 Subject: [PATCH 112/151] minor fixes of dialog --- openpype/tools/creator/widgets.py | 10 +++++----- openpype/tools/utils/error_dialog.py | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py index d2258a31c5..9dd435c1cc 100644 --- a/openpype/tools/creator/widgets.py +++ b/openpype/tools/creator/widgets.py @@ -42,7 +42,7 @@ class CreateErrorMessageBox(ErrorMessageBox): ).format( subset=self._subset_name, family=self._family, - asset=self._asset, + asset=self._asset_name, message=self._exc_msg ) if self._formatted_traceback: @@ -62,10 +62,8 @@ class CreateErrorMessageBox(ErrorMessageBox): item_name_widget = QtWidgets.QLabel(self) item_name_widget.setText( - self.convert_text_for_html( - item_name_template.format( - self._family, self._subset_name, self._asset_name - ) + item_name_template.format( + self._family, self._subset_name, self._asset_name ) ) content_layout.addWidget(item_name_widget) @@ -77,9 +75,11 @@ class CreateErrorMessageBox(ErrorMessageBox): content_layout.addWidget(message_label_widget) if self._formatted_traceback: + line_widget = self._create_line() tb_widget = self._create_traceback_widget( self._formatted_traceback ) + content_layout.addWidget(line_widget) content_layout.addWidget(tb_widget) diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py index 0336f4bb08..f7b12bb69f 100644 --- a/openpype/tools/utils/error_dialog.py +++ b/openpype/tools/utils/error_dialog.py @@ -75,6 +75,9 @@ class ErrorMessageBox(QtWidgets.QDialog): content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) + # Store content widget before creation of content + self._content_widget = content_widget + self._create_content(content_layout) content_layout.addStretch(1) @@ -104,7 +107,6 @@ class ErrorMessageBox(QtWidgets.QDialog): copy_report_btn.setVisible(False) self._report_data = report_data - self._content_widget = content_widget @staticmethod def convert_text_for_html(text): From 6f8700a5c5dd4535f8c18837949219aaadf224c9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 6 Jan 2022 12:46:05 +0100 Subject: [PATCH 113/151] OP-2282 - changes custom widget with CreatorError to standardize --- .../plugins/create/create_render.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index b796e9eaac..c73a1a1fc1 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,6 +1,7 @@ -import openpype.api -from Qt import QtWidgets from avalon import aftereffects +from avalon.api import CreatorError + +import openpype.api import logging @@ -27,14 +28,13 @@ class CreateRender(openpype.api.Creator): folders=False, footages=False) if len(items) > 1: - self._show_msg("Please select only single composition at time.") - return False + raise CreatorError("Please select only single " + "composition at time.") if not items: - self._show_msg("Nothing to create. Select composition " + - "if 'useSelection' or create at least " + - "one composition.") - return False + raise CreatorError("Nothing to create. Select composition " + + "if 'useSelection' or create at least " + + "one composition.") existing_subsets = [instance['subset'].lower() for instance in aftereffects.list_instances()] @@ -42,8 +42,7 @@ class CreateRender(openpype.api.Creator): item = items.pop() if self.name.lower() in existing_subsets: txt = "Instance with name \"{}\" already exists.".format(self.name) - self._show_msg(txt) - return False + raise CreatorError(txt) self.data["members"] = [item.id] self.data["uuid"] = item.id # for SubsetManager @@ -54,9 +53,3 @@ class CreateRender(openpype.api.Creator): stub.imprint(item, self.data) stub.set_label_color(item.id, 14) # Cyan options 0 - 16 stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) - - def _show_msg(self, txt): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(txt) - msg.exec_() From abc39a9e9f04d7efbda4b1548966794090c6f077 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 15:17:02 +0100 Subject: [PATCH 114/151] make sure output is always the same --- openpype/lib/python_module_tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 69da4cc661..903af01676 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -59,22 +59,23 @@ def modules_from_path(folder_path): """ crashed = [] modules = [] + output = (modules, crashed) # Just skip and return empty list if path is not set if not folder_path: - return modules + return output # Do not allow relative imports if folder_path.startswith("."): log.warning(( "BUG: Relative paths are not allowed for security reasons. {}" ).format(folder_path)) - return modules + return output folder_path = os.path.normpath(folder_path) if not os.path.isdir(folder_path): log.warning("Not a directory path: {}".format(folder_path)) - return modules + return output for filename in os.listdir(folder_path): # Ignore files which start with underscore @@ -101,7 +102,7 @@ def modules_from_path(folder_path): ) continue - return modules, crashed + return output def recursive_bases_from_class(klass): From 3f5b04161987ec15f937a5c0784b7389595209b3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 15:17:18 +0100 Subject: [PATCH 115/151] update docstring --- openpype/lib/python_module_tools.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 903af01676..f62c848e4a 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -49,13 +49,10 @@ def modules_from_path(folder_path): Arguments: path (str): Path to folder containing python scripts. - return_crasher (bool): Crashed module paths with exception info - will be returned too. Returns: - list, tuple: List of modules when `return_crashed` is False else tuple - with list of modules at first place and tuple of path and exception - info at second place. + tuple: First list contains successfully imported modules + and second list contains tuples of path and exception. """ crashed = [] modules = [] From e3122b9782fb1bb1ce17e6883a8d35a2c86ce12f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 6 Jan 2022 15:36:41 +0100 Subject: [PATCH 116/151] OP-2282 - added error when no layers in Background Background import without layers failed with nondescript message. Fixed broken layer naming --- .../aftereffects/plugins/load/load_background.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/load/load_background.py b/openpype/hosts/aftereffects/plugins/load/load_background.py index 9856abe3fe..4d3d46a442 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_background.py +++ b/openpype/hosts/aftereffects/plugins/load/load_background.py @@ -22,21 +22,23 @@ class BackgroundLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): items = stub.get_items(comps=True) - existing_items = [layer.name for layer in items] + existing_items = [layer.name.replace(stub.LOADED_ICON, '') + for layer in items] comp_name = get_unique_layer_name( existing_items, "{}_{}".format(context["asset"]["name"], name)) layers = get_background_layers(self.fname) + if not layers: + raise ValueError("No layers found in {}".format(self.fname)) + comp = stub.import_background(None, stub.LOADED_ICON + comp_name, layers) if not comp: - self.log.warning( - "Import background failed.") - self.log.warning("Check host app for alert error.") - return + raise ValueError("Import background failed. " + "Please contact support") self[:] = [comp] namespace = namespace or comp_name From 613e9ff2fb123b4e8606143a9a0af744cc384026 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:02:30 +0100 Subject: [PATCH 117/151] renamed get_pype_execute_args to get_openpype_execute_args --- openpype/lib/__init__.py | 2 ++ openpype/lib/execute.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 34926453cb..f721e0f577 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -24,6 +24,7 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( + get_openpype_execute_args, get_pype_execute_args, get_linux_launcher_args, execute, @@ -173,6 +174,7 @@ from .pype_info import ( terminal = Terminal __all__ = [ + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", "execute", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index f97617d906..452a8fd4c0 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -147,6 +147,11 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): + """Backwards compatible function for 'get_openpype_execute_args'.""" + return get_openpype_execute_args(*args) + + +def get_openpype_execute_args(*args): """Arguments to run pype command. Arguments for subprocess when need to spawn new pype process. Which may be From 3c1967f080504e1be3916d1deeb2241c234091e8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:13:30 +0100 Subject: [PATCH 118/151] use get_openpype_execute_args instead of get_pype_execute_args --- openpype/hooks/pre_non_python_host_launch.py | 5 ++--- openpype/hosts/tvpaint/hooks/pre_launch_args.py | 4 ++-- openpype/lib/pype_info.py | 4 ++-- .../ftrack/ftrack_server/event_server_cli.py | 6 +++--- .../default_modules/ftrack/ftrack_server/socket_thread.py | 4 ++-- openpype/modules/standalonepublish_action.py | 4 ++-- .../tools/standalonepublish/widgets/widget_components.py | 4 ++-- openpype/tools/tray/pype_tray.py | 6 +++--- 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 848ed675a8..29e40d28c8 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -3,7 +3,7 @@ import subprocess from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) from openpype import PACKAGE_DIR as OPENPYPE_DIR @@ -35,7 +35,7 @@ class NonPythonHostHook(PreLaunchHook): "non_python_host_launch.py" ) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", script_path, executable_path ) # Add workfile path if exists @@ -48,4 +48,3 @@ class NonPythonHostHook(PreLaunchHook): if remainders: self.launch_context.launch_args.extend(remainders) - diff --git a/openpype/hosts/tvpaint/hooks/pre_launch_args.py b/openpype/hosts/tvpaint/hooks/pre_launch_args.py index 62fd662d79..2a8f49d5b0 100644 --- a/openpype/hosts/tvpaint/hooks/pre_launch_args.py +++ b/openpype/hosts/tvpaint/hooks/pre_launch_args.py @@ -4,7 +4,7 @@ import shutil from openpype.hosts import tvpaint from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) import avalon @@ -30,7 +30,7 @@ class TvpaintPrelaunchHook(PreLaunchHook): while self.launch_context.launch_args: remainders.append(self.launch_context.launch_args.pop(0)) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", self.launch_script_path(), executable_path ) diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 33715e369d..15856bfb19 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -7,7 +7,7 @@ import socket import openpype.version from openpype.settings.lib import get_local_settings -from .execute import get_pype_execute_args +from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .python_module_tools import import_filepath @@ -71,7 +71,7 @@ def is_running_staging(): def get_pype_info(): """Information about currently used Pype process.""" - executable_args = get_pype_execute_args() + executable_args = get_openpype_execute_args() if is_running_from_build(): version_type = "build" else: diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py index 1a76905b38..90ce757242 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py @@ -14,7 +14,7 @@ import uuid import ftrack_api import pymongo from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, OpenPypeMongoConnection, get_openpype_version, get_build_version, @@ -136,7 +136,7 @@ def legacy_server(ftrack_url): if subproc is None: if subproc_failed_count < max_fail_count: - args = get_pype_execute_args("run", subproc_path) + args = get_openpype_execute_args("run", subproc_path) subproc = subprocess.Popen( args, stdout=subprocess.PIPE @@ -248,7 +248,7 @@ def main_loop(ftrack_url): ["Username", getpass.getuser()], ["Host Name", host_name], ["Host IP", socket.gethostbyname(host_name)], - ["OpenPype executable", get_pype_execute_args()[-1]], + ["OpenPype executable", get_openpype_execute_args()[-1]], ["OpenPype version", get_openpype_version() or "N/A"], ["OpenPype build version", get_build_version() or "N/A"] ] diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py index eb8ec4d06c..f49ca5557e 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py @@ -6,7 +6,7 @@ import threading import traceback import subprocess from openpype.api import Logger -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args class SocketThread(threading.Thread): @@ -59,7 +59,7 @@ class SocketThread(threading.Thread): env = os.environ.copy() env["OPENPYPE_PROCESS_MONGO_ID"] = str(Logger.mongo_process_id) # OpenPype executable (with path to start script if not build) - args = get_pype_execute_args( + args = get_openpype_execute_args( # Add `run` command "run", self.filepath, diff --git a/openpype/modules/standalonepublish_action.py b/openpype/modules/standalonepublish_action.py index 9321a415a9..ba53ce9b9e 100644 --- a/openpype/modules/standalonepublish_action.py +++ b/openpype/modules/standalonepublish_action.py @@ -1,7 +1,7 @@ import os import platform import subprocess -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayAction @@ -35,7 +35,7 @@ class StandAlonePublishAction(OpenPypeModule, ITrayAction): self.publish_paths.extend(publish_paths) def run_standalone_publisher(self): - args = get_pype_execute_args("standalonepublisher") + args = get_openpype_execute_args("standalonepublisher") kwargs = {} if platform.system().lower() == "darwin": new_args = ["open", "-na", args.pop(0), "--args"] diff --git a/openpype/tools/standalonepublish/widgets/widget_components.py b/openpype/tools/standalonepublish/widgets/widget_components.py index 2ac54af4e3..4d7f94f825 100644 --- a/openpype/tools/standalonepublish/widgets/widget_components.py +++ b/openpype/tools/standalonepublish/widgets/widget_components.py @@ -10,7 +10,7 @@ from .constants import HOST_NAME from avalon import io from openpype.api import execute, Logger from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, apply_project_environments_value ) @@ -193,7 +193,7 @@ def cli_publish(data, publish_paths, gui=True): project_name = os.environ["AVALON_PROJECT"] env_copy = apply_project_environments_value(project_name, envcopy) - args = get_pype_execute_args("run", PUBLISH_SCRIPT_PATH) + args = get_openpype_execute_args("run", PUBLISH_SCRIPT_PATH) result = execute(args, env=envcopy) result = {} diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 8c6a6d3266..df0238c848 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -14,7 +14,7 @@ from openpype.api import ( resources, get_system_settings ) -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import TrayModulesManager from openpype import style from openpype.settings import ( @@ -208,10 +208,10 @@ class TrayManager: First creates new process with same argument and close current tray. """ - args = get_pype_execute_args() + args = get_openpype_execute_args() # Create a copy of sys.argv additional_args = list(sys.argv) - # Check last argument from `get_pype_execute_args` + # Check last argument from `get_openpype_execute_args` # - when running from code it is the same as first from sys.argv if args[-1] == additional_args[0]: additional_args.pop(0) From 4ab54f4d735adf13f7a4db376000bc9454d2ff65 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:27:41 +0100 Subject: [PATCH 119/151] added deprecation warning --- openpype/lib/execute.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 452a8fd4c0..57cb01b4ab 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -148,6 +148,13 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): """Backwards compatible function for 'get_openpype_execute_args'.""" + import traceback + + log = Logger.get_logger("get_pype_execute_args") + stack = "\n".join(traceback.format_stack()) + log.warning(( + "Using deprecated function 'get_pype_execute_args'. Called from:\n{}" + ).format(stack)) return get_openpype_execute_args(*args) From 6de956dae2a876044bbbc77c7d023d53441e008d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:28:03 +0100 Subject: [PATCH 120/151] implemented functions to run openpype subprocess with cleanin environments --- openpype/lib/__init__.py | 4 ++++ openpype/lib/execute.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index f721e0f577..12e47a8961 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,8 @@ from .execute import ( get_linux_launcher_args, execute, run_subprocess, + run_openpype_process, + clean_envs_for_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -179,6 +181,8 @@ __all__ = [ "get_linux_launcher_args", "execute", "run_subprocess", + "run_openpype_process", + "clean_envs_for_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 57cb01b4ab..16b98eefb4 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -138,6 +138,49 @@ def run_subprocess(*args, **kwargs): return full_output +def clean_envs_for_openpype_process(env=None): + """Modify environemnts that may affect OpenPype process. + + Main reason to implement this function is to pop PYTHONPATH which may be + affected by in-host environments. + """ + if env is None: + env = os.environ + return { + key: value + for key, value in env.items() + if key not in ("PYTHONPATH",) + } + + +def run_openpype_process(*args, **kwargs): + """Execute OpenPype process with passed arguments and wait. + + Wrapper for 'run_process' which prepends OpenPype executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_openpype_process(["run", ""]) + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + """ + args = get_openpype_execute_args(*args) + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + # Skip envs that can affect OpenPype process + # - fill more if you find more + env = clean_envs_for_openpype_process(os.environ) + return run_subprocess(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. From ac6280f959cc50f980bcc37f917bc86c43d36825 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:28:43 +0100 Subject: [PATCH 121/151] use run_openpype_process in extract burnin --- openpype/plugins/publish/extract_burnin.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index df7dc47e17..1cb8608a56 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -13,7 +13,7 @@ import pyblish import openpype import openpype.api from openpype.lib import ( - get_pype_execute_args, + run_openpype_process, get_transcode_temp_directory, convert_for_ffmpeg, @@ -168,9 +168,8 @@ class ExtractBurnin(openpype.api.Extractor): anatomy = instance.context.data["anatomy"] scriptpath = self.burnin_script_path() - # Executable args that will execute the script - # [pype executable, *pype script, "run"] - executable_args = get_pype_execute_args("run", scriptpath) + # Args that will execute the script + executable_args = ["run", scriptpath] burnins_per_repres = self._get_burnins_per_representations( instance, burnin_defs ) @@ -313,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - openpype.api.run_subprocess(args, **process_kwargs) + run_openpype_process(args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) From ed5da3e0b04b28019e2b5c192364ced71d9a2c27 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:39:59 +0100 Subject: [PATCH 122/151] expect that get_remote_versions may return None --- openpype/settings/entities/op_version_entity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 6184f7640a..782d65a446 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -72,6 +72,8 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=False, production=True) + if versions is None: + return [] versions.append(get_installed_version()) return sorted(versions) @@ -82,4 +84,6 @@ class StagingVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=True, production=False) + if versions is None: + return [] return sorted(versions) From bbbfef4ca793765e0930f7f95af93f655303cbec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:32:54 +0100 Subject: [PATCH 123/151] changed action/event paths to multiplatform and multipath inputs and changed labels --- .../defaults/system_settings/modules.json | 12 ++++++++++-- .../module_settings/schema_ftrack.json | 18 +++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index f0caa153de..b31dd6856c 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -15,8 +15,16 @@ "ftrack": { "enabled": true, "ftrack_server": "", - "ftrack_actions_path": [], - "ftrack_events_path": [], + "ftrack_actions_path": { + "windows": [], + "darwin": [], + "linux": [] + }, + "ftrack_events_path": { + "windows": [], + "darwin": [], + "linux": [] + }, "intent": { "items": { "-": "-", diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json index 5f659522c3..654ddf2938 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json @@ -21,19 +21,23 @@ }, { "type": "label", - "label": "Additional Ftrack paths" + "label": "Additional Ftrack event handlers paths" }, { - "type": "list", + "type": "path", "key": "ftrack_actions_path", - "label": "Action paths", - "object_type": "text" + "label": "User paths", + "use_label_wrap": true, + "multipath": true, + "multiplatform": true }, { - "type": "list", + "type": "path", "key": "ftrack_events_path", - "label": "Event paths", - "object_type": "text" + "label": "Server paths", + "use_label_wrap": true, + "multipath": true, + "multiplatform": true }, { "type": "separator" From 5ec147b336afe2499d428cbc81251b629d6e5ee5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:42:36 +0100 Subject: [PATCH 124/151] ftrack module expect new type of path settings --- .../default_modules/ftrack/ftrack_module.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 8a7525d65b..e24869fd59 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -1,6 +1,7 @@ import os import json import collections +import platform import click @@ -42,18 +43,28 @@ class FtrackModule( self.ftrack_url = ftrack_url current_dir = os.path.dirname(os.path.abspath(__file__)) + low_platform = platform.system().lower() + server_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_server") ] - server_event_handlers_paths.extend( - ftrack_settings["ftrack_events_path"] - ) + settings_server_paths = ftrack_settings["ftrack_events_path"] + if isinstance(settings_server_paths, dict): + settings_server_paths = settings_server_paths[low_platform] + + for path in settings_server_paths: + server_event_handlers_paths.append(path) + user_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_user") ] - user_event_handlers_paths.extend( - ftrack_settings["ftrack_actions_path"] - ) + settings_action_paths = ftrack_settings["ftrack_actions_path"] + if isinstance(settings_action_paths, dict): + settings_action_paths = settings_action_paths[low_platform] + + for path in settings_action_paths: + user_event_handlers_paths.append(path) + # Prepare attribute self.server_event_handlers_paths = server_event_handlers_paths self.user_event_handlers_paths = user_event_handlers_paths From dd80f331f70ab7d51a32f4fab67871bb69e497ee Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:42:51 +0100 Subject: [PATCH 125/151] paths to event handler are tried to format with environments --- .../modules/default_modules/ftrack/ftrack_module.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index e24869fd59..1b41159069 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -53,6 +53,11 @@ class FtrackModule( settings_server_paths = settings_server_paths[low_platform] for path in settings_server_paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass server_event_handlers_paths.append(path) user_event_handlers_paths = [ @@ -63,6 +68,11 @@ class FtrackModule( settings_action_paths = settings_action_paths[low_platform] for path in settings_action_paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass user_event_handlers_paths.append(path) # Prepare attribute From 85d973555b01d28f1b39f58f8f2bc93d2d229606 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:44:41 +0100 Subject: [PATCH 126/151] try format the paths in ftrack event server (more dynamic) --- .../default_modules/ftrack/ftrack_module.py | 20 ++++--------------- .../ftrack/ftrack_server/ftrack_server.py | 6 ++++++ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 1b41159069..38ec02749a 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -45,35 +45,23 @@ class FtrackModule( current_dir = os.path.dirname(os.path.abspath(__file__)) low_platform = platform.system().lower() + # Server event handler paths server_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_server") ] settings_server_paths = ftrack_settings["ftrack_events_path"] if isinstance(settings_server_paths, dict): settings_server_paths = settings_server_paths[low_platform] + server_event_handlers_paths.extend(settings_server_paths) - for path in settings_server_paths: - # Try to format path with environments - try: - path = path.format(**os.environ) - except BaseException: - pass - server_event_handlers_paths.append(path) - + # User event handler paths user_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_user") ] settings_action_paths = ftrack_settings["ftrack_actions_path"] if isinstance(settings_action_paths, dict): settings_action_paths = settings_action_paths[low_platform] - - for path in settings_action_paths: - # Try to format path with environments - try: - path = path.format(**os.environ) - except BaseException: - pass - user_event_handlers_paths.append(path) + user_event_handlers_paths.extend(settings_action_paths) # Prepare attribute self.server_event_handlers_paths = server_event_handlers_paths diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py b/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py index bd67fba3d6..8944591b71 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py @@ -63,6 +63,12 @@ class FtrackServer: # Iterate all paths register_functions = [] for path in paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass + # Get all modules with functions modules, crashed = modules_from_path(path) for filepath, exc_info in crashed: From d1b4ac5d40cb922dc678e472bef71d61ebeb3582 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 15:52:19 +0100 Subject: [PATCH 127/151] moved timers manager one hierarchy higher --- openpype/modules/{default_modules => }/timers_manager/__init__.py | 0 .../modules/{default_modules => }/timers_manager/exceptions.py | 0 .../modules/{default_modules => }/timers_manager/idle_threads.py | 0 .../timers_manager/launch_hooks/post_start_timer.py | 0 openpype/modules/{default_modules => }/timers_manager/rest_api.py | 0 .../{default_modules => }/timers_manager/timers_manager.py | 0 .../{default_modules => }/timers_manager/widget_user_idle.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename openpype/modules/{default_modules => }/timers_manager/__init__.py (100%) rename openpype/modules/{default_modules => }/timers_manager/exceptions.py (100%) rename openpype/modules/{default_modules => }/timers_manager/idle_threads.py (100%) rename openpype/modules/{default_modules => }/timers_manager/launch_hooks/post_start_timer.py (100%) rename openpype/modules/{default_modules => }/timers_manager/rest_api.py (100%) rename openpype/modules/{default_modules => }/timers_manager/timers_manager.py (100%) rename openpype/modules/{default_modules => }/timers_manager/widget_user_idle.py (100%) diff --git a/openpype/modules/default_modules/timers_manager/__init__.py b/openpype/modules/timers_manager/__init__.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/__init__.py rename to openpype/modules/timers_manager/__init__.py diff --git a/openpype/modules/default_modules/timers_manager/exceptions.py b/openpype/modules/timers_manager/exceptions.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/exceptions.py rename to openpype/modules/timers_manager/exceptions.py diff --git a/openpype/modules/default_modules/timers_manager/idle_threads.py b/openpype/modules/timers_manager/idle_threads.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/idle_threads.py rename to openpype/modules/timers_manager/idle_threads.py diff --git a/openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py rename to openpype/modules/timers_manager/launch_hooks/post_start_timer.py diff --git a/openpype/modules/default_modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/rest_api.py rename to openpype/modules/timers_manager/rest_api.py diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/timers_manager.py rename to openpype/modules/timers_manager/timers_manager.py diff --git a/openpype/modules/default_modules/timers_manager/widget_user_idle.py b/openpype/modules/timers_manager/widget_user_idle.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/widget_user_idle.py rename to openpype/modules/timers_manager/widget_user_idle.py From 00118b249a6555d1c75e077399d9a3a4a87bf1f6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 15:52:32 +0100 Subject: [PATCH 128/151] added timers manager to default modules --- openpype/modules/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b5c491a1c0..d566692439 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -42,6 +42,7 @@ DEFAULT_OPENPYPE_MODULES = ( "settings_action", "standalonepublish_action", "job_queue", + "timers_manager", ) From b56fabbf8c01e2e5b82112e340c2a2a835018afd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:34:28 +0100 Subject: [PATCH 129/151] added new image and define new color for delete button --- openpype/style/data.json | 3 +++ openpype/style/style.css | 7 ++++++ .../project_manager/images/warning.png | Bin 0 -> 9393 bytes .../project_manager/project_manager/style.py | 21 ++++++++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/images/warning.png diff --git a/openpype/style/data.json b/openpype/style/data.json index 026eaf4264..205e30563b 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,6 +51,9 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", + "delete-btn-bg": "rgb(201, 54, 54)", + "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index 4159fe1676..8f613f3888 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -713,6 +713,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } +#DeleteButton { + background: {color:delete-btn-bg}; +} +#DeleteButton:disabled { + background: {color:delete-btn-bg-disabled}; +} + /* Launcher specific stylesheets */ #IconView[mode="icon"] { /* font size can't be set on items */ diff --git a/openpype/tools/project_manager/project_manager/images/warning.png b/openpype/tools/project_manager/project_manager/images/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..3b4ae861f9f3982195c82d681d4b3f31cdea9df8 GIT binary patch literal 9393 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 H|MdR=mHnnz literal 0 HcmV?d00001 diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index d3d6857a63..9fa7a5520b 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -1,6 +1,7 @@ import os from Qt import QtCore, QtGui +from openpype.style import get_objected_colors from avalon.vendor import qtawesome @@ -90,6 +91,17 @@ class ResourceCache: icon.addPixmap(disabled_pix, QtGui.QIcon.Disabled, QtGui.QIcon.Off) return icon + @classmethod + def get_warning_pixmap(cls): + src_image = get_warning_image() + colors = get_objected_colors() + color_value = colors["delete-btn-bg"] + + return paint_image_with_color( + src_image, + color_value.get_qcolor() + ) + def get_remove_image(): image_path = os.path.join( @@ -100,6 +112,15 @@ def get_remove_image(): return QtGui.QImage(image_path) +def get_warning_image(): + image_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "warning.png" + ) + return QtGui.QImage(image_path) + + def paint_image_with_color(image, color): """TODO: This function should be imported from utils. From 53ad6027c5d30c365a8ea52da59f83b8c708a169 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:36:26 +0100 Subject: [PATCH 130/151] changed buttons classes and mark delete btn with DeleteButton object name --- openpype/tools/project_manager/project_manager/widgets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 392f3f4503..20a6955d81 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -352,9 +352,10 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): "Type \"{}\" to confirm...".format(project_name) ) - cancel_btn = _SameSizeBtns("Cancel", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") - confirm_btn = _SameSizeBtns("Delete", self) + confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) + confirm_btn.setObjectName("DeleteButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") From 857d22690deb8bc95df8bef3d1c2bf164beeb6b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:36:50 +0100 Subject: [PATCH 131/151] added warning pixmap into dialog --- .../project_manager/widgets.py | 87 +++++++++++-------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 20a6955d81..45599ab747 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -4,6 +4,7 @@ from .constants import ( NAME_ALLOWED_SYMBOLS, NAME_REGEX ) +from .style import ResourceCache from openpype.lib import ( create_project, PROJECT_NAME_ALLOWED_SYMBOLS, @@ -13,7 +14,7 @@ from openpype.style import load_stylesheet from openpype.tools.utils import PlaceholderLineEdit from avalon.api import AvalonMongoDB -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui class NameTextEdit(QtWidgets.QLineEdit): @@ -291,42 +292,41 @@ class CreateProjectDialog(QtWidgets.QDialog): return project_names, project_codes -class _SameSizeBtns(QtWidgets.QPushButton): - """Button that keep width of all button added as related. +# TODO PixmapLabel should be moved to 'utils' in other future PR so should be +# imported from there +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap - This happens without changing min/max/fix size of button. Which is - welcomed for multidisplay desktops with different resolution. - """ - def __init__(self, *args, **kwargs): - super(_SameSizeBtns, self).__init__(*args, **kwargs) - self._related_btns = [] + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() - def add_related_btn(self, btn): - """Add related button which should be checked for width. + def _get_pix_size(self): + size = self.fontMetrics().height() * 4 + return size, size - Args: - btn (_SameSizeBtns): Other object of _SameSizeBtns. - """ - self._related_btns.append(btn) + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) - def hint_width(self): - """Get size hint of button not related to others.""" - return super(_SameSizeBtns, self).sizeHint().width() - - def sizeHint(self): - """Calculate size hint based on size hint of this button and related. - - If width is lower than any other button it is changed to higher. - """ - result = super(_SameSizeBtns, self).sizeHint() - width = result.width() - for btn in self._related_btns: - btn_width = btn.hint_width() - if btn_width > width: - width = btn_width - - result.setWidth(width) - return result + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) class ConfirmProjectDeletion(QtWidgets.QDialog): @@ -336,15 +336,29 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message_label = QtWidgets.QLabel(self) + top_widget = QtWidgets.QWidget(self) + + warning_pixmap = ResourceCache.get_warning_pixmap() + warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + + message_label = QtWidgets.QLabel(top_widget) message_label.setWordWrap(True) message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) message_label.setText(( + "WARNING: This cannot be undone.

" "Project \"{}\" with all related data will be" " permanently removed from the database (This actions won't remove" " any files on disk)." ).format(project_name)) + top_layout = QtWidgets.QHBoxLayout(top_widget) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget( + warning_icon_label, 0, + QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + ) + top_layout.addWidget(message_label, 1) + question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) @@ -359,16 +373,13 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") - cancel_btn.add_related_btn(confirm_btn) - confirm_btn.add_related_btn(cancel_btn) - btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(message_label, 0) + layout.addWidget(top_widget, 0) layout.addStretch(1) layout.addWidget(question_label, 0) layout.addWidget(confirm_input, 0) From f306de1faa9d04fbe1fbfd64c290e3fdc7039cc1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 21:29:23 +0100 Subject: [PATCH 132/151] cosmetic changes in label --- openpype/tools/project_manager/project_manager/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 45599ab747..4b5aca35ef 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -347,8 +347,8 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): message_label.setText(( "WARNING: This cannot be undone.

" "Project \"{}\" with all related data will be" - " permanently removed from the database (This actions won't remove" - " any files on disk)." + " permanently removed from the database. (This action won't remove" + " any files on disk.)" ).format(project_name)) top_layout = QtWidgets.QHBoxLayout(top_widget) From 2a67e7bb272b166a258819684cd4932007c2bdf0 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 8 Jan 2022 03:43:20 +0000 Subject: [PATCH 133/151] [Automated] Bump version --- CHANGELOG.md | 16 ++++++++++++++-- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46b1f37e1..20ab087690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.8.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) @@ -10,6 +10,8 @@ **🚀 Enhancements** +- Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) +- Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) @@ -19,8 +21,19 @@ **🐛 Bug fixes** +- General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) +- Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) +- General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) +- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) + +**Merged pull requests:** + +- General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) +- AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) +- Maya: Validate NGONs re-use polyConstraint code from openpype.host.maya.api.lib [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) +- Version handling [\#2363](https://github.com/pypeclub/OpenPype/pull/2363) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) @@ -67,7 +80,6 @@ - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) -- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) diff --git a/openpype/version.py b/openpype/version.py index 4f10deeae1..ed0a96d4de 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.1" +__version__ = "3.8.0-nightly.2" diff --git a/pyproject.toml b/pyproject.toml index 20aaf62d06..0ef447e0be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.1" # OpenPype +version = "3.8.0-nightly.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 2a0f7b48d99d95219db0816b23679e965178cdf2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 14:06:12 +0100 Subject: [PATCH 134/151] fix how run_openpype_process works --- openpype/lib/execute.py | 2 +- openpype/plugins/publish/extract_burnin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 16b98eefb4..3cf67a379c 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -164,7 +164,7 @@ def run_openpype_process(*args, **kwargs): Example: ``` - run_openpype_process(["run", ""]) + run_openpype_process("run", "") ``` Args: diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 1cb8608a56..459c66ee43 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -312,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - run_openpype_process(args, **process_kwargs) + run_openpype_process(*args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) From e94aa5311651c3219c5afe8219b0c5ecaa35e4c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:00:45 +0100 Subject: [PATCH 135/151] moved photoshop from avalon to openpype --- openpype/hosts/photoshop/api/README.md | 255 ++++ openpype/hosts/photoshop/api/__init__.py | 114 +- openpype/hosts/photoshop/api/extension.zxp | Bin 0 -> 54111 bytes openpype/hosts/photoshop/api/extension/.debug | 9 + .../photoshop/api/extension/CSXS/manifest.xml | 53 + .../api/extension/client/CSInterface.js | 1193 +++++++++++++++++ .../photoshop/api/extension/client/client.js | 300 +++++ .../api/extension/client/loglevel.min.js | 2 + .../photoshop/api/extension/client/wsrpc.js | 393 ++++++ .../api/extension/client/wsrpc.min.js | 1 + .../hosts/photoshop/api/extension/host/JSX.js | 774 +++++++++++ .../photoshop/api/extension/host/index.jsx | 484 +++++++ .../photoshop/api/extension/host/json.js | 530 ++++++++ .../api/extension/icons/avalon-logo-48.png | Bin 0 -> 1362 bytes .../hosts/photoshop/api/extension/index.html | 119 ++ openpype/hosts/photoshop/api/launch_logic.py | 315 +++++ openpype/hosts/photoshop/api/lib.py | 76 ++ openpype/hosts/photoshop/api/panel.PNG | Bin 0 -> 8756 bytes .../hosts/photoshop/api/panel_failure.PNG | Bin 0 -> 13568 bytes openpype/hosts/photoshop/api/pipeline.py | 199 +++ openpype/hosts/photoshop/api/workio.py | 50 + openpype/hosts/photoshop/api/ws_stub.py | 470 +++++++ 22 files changed, 5268 insertions(+), 69 deletions(-) create mode 100644 openpype/hosts/photoshop/api/README.md create mode 100644 openpype/hosts/photoshop/api/extension.zxp create mode 100644 openpype/hosts/photoshop/api/extension/.debug create mode 100644 openpype/hosts/photoshop/api/extension/CSXS/manifest.xml create mode 100644 openpype/hosts/photoshop/api/extension/client/CSInterface.js create mode 100644 openpype/hosts/photoshop/api/extension/client/client.js create mode 100644 openpype/hosts/photoshop/api/extension/client/loglevel.min.js create mode 100644 openpype/hosts/photoshop/api/extension/client/wsrpc.js create mode 100644 openpype/hosts/photoshop/api/extension/client/wsrpc.min.js create mode 100644 openpype/hosts/photoshop/api/extension/host/JSX.js create mode 100644 openpype/hosts/photoshop/api/extension/host/index.jsx create mode 100644 openpype/hosts/photoshop/api/extension/host/json.js create mode 100644 openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png create mode 100644 openpype/hosts/photoshop/api/extension/index.html create mode 100644 openpype/hosts/photoshop/api/launch_logic.py create mode 100644 openpype/hosts/photoshop/api/lib.py create mode 100644 openpype/hosts/photoshop/api/panel.PNG create mode 100644 openpype/hosts/photoshop/api/panel_failure.PNG create mode 100644 openpype/hosts/photoshop/api/pipeline.py create mode 100644 openpype/hosts/photoshop/api/workio.py create mode 100644 openpype/hosts/photoshop/api/ws_stub.py diff --git a/openpype/hosts/photoshop/api/README.md b/openpype/hosts/photoshop/api/README.md new file mode 100644 index 0000000000..b958f53803 --- /dev/null +++ b/openpype/hosts/photoshop/api/README.md @@ -0,0 +1,255 @@ +# Photoshop Integration + +## Setup + +The Photoshop integration requires two components to work; `extension` and `server`. + +### Extension + +To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). + +``` +ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +``` + +### Server + +The easiest way to get the server and Photoshop launch is with: + +``` +python -c ^"import avalon.photoshop;avalon.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" +``` + +`avalon.photoshop.launch` launches the application and server, and also closes the server when Photoshop exists. + +## Usage + +The Photoshop extension can be found under `Window > Extensions > Avalon`. Once launched you should be presented with a panel like this: + +![Avalon Panel](panel.PNG "Avalon Panel") + + +## Developing + +### Extension +When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). + +When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). + +``` +ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-Photoshop avalon extension.p12 +ZXPSignCmd -sign {path to avalon-core}\avalon\photoshop\extension {path to avalon-core}\avalon\photoshop\extension.zxp extension.p12 avalon +``` + +### Plugin Examples + +These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). + +#### Creator Plugin +```python +from avalon import photoshop + + +class CreateImage(photoshop.Creator): + """Image folder for publish.""" + + name = "imageDefault" + label = "Image" + family = "image" + + def __init__(self, *args, **kwargs): + super(CreateImage, self).__init__(*args, **kwargs) +``` + +#### Collector Plugin +```python +import pythoncom + +import pyblish.api + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by LayerSet and file metadata + + This collector takes into account assets that are associated with + an LayerSet and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance" + """ + + label = "Instances" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + families_mapping = { + "image": [] + } + + def process(self, context): + # Necessary call when running in a different thread which pyblish-qml + # can be. + pythoncom.CoInitialize() + + photoshop_client = PhotoshopClientStub() + layers = photoshop_client.get_layers() + layers_meta = photoshop_client.get_layers_metadata() + for layer in layers: + layer_data = photoshop_client.read(layer, layers_meta) + + # Skip layers without metadata. + if layer_data is None: + continue + + # Skip containers. + if "container" in layer_data["id"]: + continue + + # child_layers = [*layer.Layers] + # self.log.debug("child_layers {}".format(child_layers)) + # if not child_layers: + # self.log.info("%s skipped, it was empty." % layer.Name) + # continue + + instance = context.create_instance(layer.name) + instance.append(layer) + instance.data.update(layer_data) + instance.data["families"] = self.families_mapping[ + layer_data["family"] + ] + instance.data["publish"] = layer.visible + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) +``` + +#### Extractor Plugin +```python +import os + +import openpype.api +from avalon import photoshop + + +class ExtractImage(openpype.api.Extractor): + """Produce a flattened image file from instance + + This plug-in takes into account only the layers in the group. + """ + + label = "Extract Image" + hosts = ["photoshop"] + families = ["image"] + formats = ["png", "jpg"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) + + # Perform extraction + stub = photoshop.stub() + files = {} + with photoshop.maintained_selection(): + self.log.info("Extracting %s" % str(list(instance))) + with photoshop.maintained_visibility(): + # Hide all other layers. + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers([instance[0]])]) + + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + + save_options = [] + if "png" in self.formats: + save_options.append('png') + if "jpg" in self.formats: + save_options.append('jpg') + + file_basename = os.path.splitext( + stub.get_active_document_name() + )[0] + for extension in save_options: + _filename = "{}.{}".format(file_basename, extension) + files[extension] = _filename + + full_filename = os.path.join(staging_dir, _filename) + stub.saveAs(full_filename, extension, True) + + representations = [] + for extension, filename in files.items(): + representations.append({ + "name": extension, + "ext": extension, + "files": filename, + "stagingDir": staging_dir + }) + instance.data["representations"] = representations + instance.data["stagingDir"] = staging_dir + + self.log.info(f"Extracted {instance} to {staging_dir}") +``` + +#### Loader Plugin +```python +from avalon import api, photoshop + +stub = photoshop.stub() + + +class ImageLoader(api.Loader): + """Load images + + Stores the imported asset in a container named after the asset. + """ + + families = ["image"] + representations = ["*"] + + def load(self, context, name=None, namespace=None, data=None): + with photoshop.maintained_selection(): + layer = stub.import_smart_object(self.fname) + + self[:] = [layer] + + return photoshop.containerise( + name, + namespace, + layer, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + layer = container.pop("layer") + + with photoshop.maintained_selection(): + stub.replace_smart_object( + layer, api.get_representation_path(representation) + ) + + stub.imprint( + layer, {"representation": str(representation["_id"])} + ) + + def remove(self, container): + container["layer"].Delete() + + def switch(self, container, representation): + self.update(container, representation) +``` +For easier debugging of Javascript: +https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 +Add --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome +then localhost:8078 (port set in `photoshop\extension\.debug`) + +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 + +Or install CEF client from https://github.com/Adobe-CEP/CEP-Resources/tree/master/CEP_9.x +## Resources + - https://github.com/lohriialo/photoshop-scripting-python + - https://www.adobe.com/devnet/photoshop/scripting.html + - https://github.com/Adobe-CEP/Getting-Started-guides + - https://github.com/Adobe-CEP/CEP-Resources diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index d978d6ecc1..43756b9ee4 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -1,79 +1,55 @@ -import os -import sys -import logging +"""Public API -from Qt import QtWidgets +Anything that isn't defined here is INTERNAL and unreliable for external use. -from avalon import io -from avalon import api as avalon -from openpype import lib -from pyblish import api as pyblish -import openpype.hosts.photoshop +""" -log = logging.getLogger("openpype.hosts.photoshop") +from .pipeline import ( + ls, + list_instances, + remove_instance, + Creator, + install, + containerise +) -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) -def check_inventory(): - if not lib.any_outdated(): - return +from .lib import ( + maintained_selection, + maintained_visibility +) - host = avalon.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) +from .launch_logic import stub - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) +__all__ = [ + # pipeline + "ls", + "list_instances", + "remove_instance", + "Creator", + "install", + "containerise", - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() + # workfiles + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", - # Garbage collect QApplication. - del app + # lib + "maintained_selection", + "maintained_visibility", - -def application_launch(): - check_inventory() - - -def install(): - print("Installing Pype config...") - - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - - avalon.on("application.launched", application_launch) - -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value + # launch_logic + "stub" +] diff --git a/openpype/hosts/photoshop/api/extension.zxp b/openpype/hosts/photoshop/api/extension.zxp new file mode 100644 index 0000000000000000000000000000000000000000..a25ec96e7df2348e558277faedf4f74561a7f316 GIT binary patch literal 54111 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� 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¨9*zdQ|q^=Mf~ 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 z + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml new file mode 100644 index 0000000000..6396cd2412 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + ./index.html + + + + true + + + applicationActivate + com.adobe.csxs.events.ApplicationInitialized + + + + Panel + OpenPype + + + 300 + 140 + + + 400 + 200 + + + + ./icons/avalon-logo-48.png + + + + + + diff --git a/openpype/hosts/photoshop/api/extension/client/CSInterface.js b/openpype/hosts/photoshop/api/extension/client/CSInterface.js new file mode 100644 index 0000000000..4239391efd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/CSInterface.js @@ -0,0 +1,1193 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v8.0.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
    + *
  • Access information about the host application in which an extension is running
  • + *
  • Launch an extension
  • + *
  • Register interest in event notifications, and dispatch events
  • + *
+ * + * @return A new \c CSInterface object + */ +function CSInterface() +{ +} + +/** + * User can add this event listener to handle native application theme color changes. + * Callback function gives extensions ability to fine-tune their theme color after the + * global theme color has been changed. + * The callback function should be like below: + * + * @example + * // event is a CSEvent object, but user can ignore it. + * function OnAppThemeColorChanged(event) + * { + * // Should get a latest HostEnvironment object from application. + * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; + * // Gets the style information such as color info from the skinInfo, + * // and redraw all UI controls of your extension according to the style info. + * } + */ +CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; + +/** The host environment data object. */ +CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; + +/** Retrieves information about the host environment in which the + * extension is currently running. + * + * @return A \c #HostEnvironment object. + */ +CSInterface.prototype.getHostEnvironment = function() +{ + this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); + return this.hostEnvironment; +}; + +/** Closes this extension. */ +CSInterface.prototype.closeExtension = function() +{ + window.__adobe_cep__.closeExtension(); +}; + +/** + * Retrieves a path for which a constant is defined in the system. + * + * @param pathType The path-type constant defined in \c #SystemPath , + * + * @return The platform-specific system path string. + */ +CSInterface.prototype.getSystemPath = function(pathType) +{ + var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); + var OSVersion = this.getOSInformation(); + if (OSVersion.indexOf("Windows") >= 0) + { + path = path.replace("file:///", ""); + } + else if (OSVersion.indexOf("Mac") >= 0) + { + path = path.replace("file://", ""); + } + return path; +}; + +/** + * Evaluates a JavaScript script, which can use the JavaScript DOM + * of the host application. + * + * @param script The JavaScript script. + * @param callback Optional. A callback function that receives the result of execution. + * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. + */ +CSInterface.prototype.evalScript = function(script, callback) +{ + if(callback === null || callback === undefined) + { + callback = function(result){}; + } + window.__adobe_cep__.evalScript(script, callback); +}; + +/** + * Retrieves the unique identifier of the application. + * in which the extension is currently running. + * + * @return The unique ID string. + */ +CSInterface.prototype.getApplicationID = function() +{ + var appId = this.hostEnvironment.appId; + return appId; +}; + +/** + * Retrieves host capability information for the application + * in which the extension is currently running. + * + * @return A \c #HostCapabilities object. + */ +CSInterface.prototype.getHostCapabilities = function() +{ + var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); + return hostCapabilities; +}; + +/** + * Triggers a CEP event programmatically. Yoy can use it to dispatch + * an event of a predefined type, or of a type you have defined. + * + * @param event A \c CSEvent object. + */ +CSInterface.prototype.dispatchEvent = function(event) +{ + if (typeof event.data == "object") + { + event.data = JSON.stringify(event.data); + } + + window.__adobe_cep__.dispatchEvent(event); +}; + +/** + * Registers an interest in a CEP event of a particular type, and + * assigns an event handler. + * The event infrastructure notifies your extension when events of this type occur, + * passing the event object to the registered handler function. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.addEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.addEventListener(type, listener, obj); +}; + +/** + * Removes a registered event listener. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method that was registered. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.removeEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.removeEventListener(type, listener, obj); +}; + +/** + * Loads and launches another extension, or activates the extension if it is already loaded. + * + * @param extensionId The extension's unique identifier. + * @param startupParams Not currently used, pass "". + * + * @example + * To launch the extension "help" with ID "HLP" from this extension, call: + * requestOpenExtension("HLP", ""); + * + */ +CSInterface.prototype.requestOpenExtension = function(extensionId, params) +{ + window.__adobe_cep__.requestOpenExtension(extensionId, params); +}; + +/** + * Retrieves the list of extensions currently loaded in the current host application. + * The extension list is initialized once, and remains the same during the lifetime + * of the CEP session. + * + * @param extensionIds Optional, an array of unique identifiers for extensions of interest. + * If omitted, retrieves data for all extensions. + * + * @return Zero or more \c #Extension objects. + */ +CSInterface.prototype.getExtensions = function(extensionIds) +{ + var extensionIdsStr = JSON.stringify(extensionIds); + var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); + + var extensions = JSON.parse(extensionsStr); + return extensions; +}; + +/** + * Retrieves network-related preferences. + * + * @return A JavaScript object containing network preferences. + */ +CSInterface.prototype.getNetworkPreferences = function() +{ + var result = window.__adobe_cep__.getNetworkPreferences(); + var networkPre = JSON.parse(result); + + return networkPre; +}; + +/** + * Initializes the resource bundle for this extension with property values + * for the current application and locale. + * To support multiple locales, you must define a property file for each locale, + * containing keyed display-string values for that locale. + * See localization documentation for Extension Builder and related products. + * + * Keys can be in the + * form key.value="localized string", for use in HTML text elements. + * For example, in this input element, the localized \c key.value string is displayed + * instead of the empty \c value string: + * + * + * + * @return An object containing the resource bundle information. + */ +CSInterface.prototype.initResourceBundle = function() +{ + var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); + var resElms = document.querySelectorAll('[data-locale]'); + for (var n = 0; n < resElms.length; n++) + { + var resEl = resElms[n]; + // Get the resource key from the element. + var resKey = resEl.getAttribute('data-locale'); + if (resKey) + { + // Get all the resources that start with the key. + for (var key in resourceBundle) + { + if (key.indexOf(resKey) === 0) + { + var resValue = resourceBundle[key]; + if (key.length == resKey.length) + { + resEl.innerHTML = resValue; + } + else if ('.' == key.charAt(resKey.length)) + { + var attrKey = key.substring(resKey.length + 1); + resEl[attrKey] = resValue; + } + } + } + } + } + return resourceBundle; +}; + +/** + * Writes installation information to a file. + * + * @return The file path. + */ +CSInterface.prototype.dumpInstallationInfo = function() +{ + return window.__adobe_cep__.dumpInstallationInfo(); +}; + +/** + * Retrieves version information for the current Operating System, + * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. + * + * @return A string containing the OS version, or "unknown Operation System". + * If user customizes the User Agent by setting CEF command parameter "--user-agent", only + * "Mac OS X" or "Windows" will be returned. + */ +CSInterface.prototype.getOSInformation = function() +{ + var userAgent = navigator.userAgent; + + if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) + { + var winVersion = "Windows"; + var winBit = ""; + if (userAgent.indexOf("Windows") > -1) + { + if (userAgent.indexOf("Windows NT 5.0") > -1) + { + winVersion = "Windows 2000"; + } + else if (userAgent.indexOf("Windows NT 5.1") > -1) + { + winVersion = "Windows XP"; + } + else if (userAgent.indexOf("Windows NT 5.2") > -1) + { + winVersion = "Windows Server 2003"; + } + else if (userAgent.indexOf("Windows NT 6.0") > -1) + { + winVersion = "Windows Vista"; + } + else if (userAgent.indexOf("Windows NT 6.1") > -1) + { + winVersion = "Windows 7"; + } + else if (userAgent.indexOf("Windows NT 6.2") > -1) + { + winVersion = "Windows 8"; + } + else if (userAgent.indexOf("Windows NT 6.3") > -1) + { + winVersion = "Windows 8.1"; + } + else if (userAgent.indexOf("Windows NT 10") > -1) + { + winVersion = "Windows 10"; + } + + if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) + { + winBit = " 64-bit"; + } + else + { + winBit = " 32-bit"; + } + } + + return winVersion + winBit; + } + else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) + { + var result = "Mac OS X"; + + if (userAgent.indexOf("Mac OS X") > -1) + { + result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); + result = result.replace(/_/g, "."); + } + + return result; + } + + return "Unknown Operation System"; +}; + +/** + * Opens a page in the default system browser. + * + * Since 4.2.0 + * + * @param url The URL of the page/file to open, or the email address. + * Must use HTTP/HTTPS/file/mailto protocol. For example: + * "http://www.adobe.com" + * "https://github.com" + * "file:///C:/log.txt" + * "mailto:test@adobe.com" + * + * @return One of these error codes:\n + *
    \n + *
  • NO_ERROR - 0
  • \n + *
  • ERR_UNKNOWN - 1
  • \n + *
  • ERR_INVALID_PARAMS - 2
  • \n + *
  • ERR_INVALID_URL - 201
  • \n + *
\n + */ +CSInterface.prototype.openURLInDefaultBrowser = function(url) +{ + return cep.util.openURLInDefaultBrowser(url); +}; + +/** + * Retrieves extension ID. + * + * Since 4.2.0 + * + * @return extension ID. + */ +CSInterface.prototype.getExtensionID = function() +{ + return window.__adobe_cep__.getExtensionId(); +}; + +/** + * Retrieves the scale factor of screen. + * On Windows platform, the value of scale factor might be different from operating system's scale factor, + * since host application may use its self-defined scale factor. + * + * Since 4.2.0 + * + * @return One of the following float number. + *
    \n + *
  • -1.0 when error occurs
  • \n + *
  • 1.0 means normal screen
  • \n + *
  • >1.0 means HiDPI screen
  • \n + *
\n + */ +CSInterface.prototype.getScaleFactor = function() +{ + return window.__adobe_cep__.getScaleFactor(); +}; + +/** + * Set a handler to detect any changes of scale factor. This only works on Mac. + * + * Since 4.2.0 + * + * @param handler The function to be called when scale factor is changed. + * + */ +CSInterface.prototype.setScaleFactorChangedHandler = function(handler) +{ + window.__adobe_cep__.setScaleFactorChangedHandler(handler); +}; + +/** + * Retrieves current API version. + * + * Since 4.2.0 + * + * @return ApiVersion object. + * + */ +CSInterface.prototype.getCurrentApiVersion = function() +{ + var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); + return apiVersion; +}; + +/** + * Set panel flyout menu by an XML. + * + * Since 5.2.0 + * + * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a + * menu item is clicked. + * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. + * + * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" + * respectively to get notified when flyout menu is opened or closed. + * + * @param menu A XML string which describes menu structure. + * An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setPanelFlyoutMenu = function(menu) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); +}; + +/** + * Updates a menu item in the extension window's flyout menu, by setting the enabled + * and selection status. + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + * + * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). + * Fails silently if menu label is invalid. + * + * @see HostCapabilities.EXTENDED_PANEL_MENU + */ +CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) +{ + var ret = false; + if (this.getHostCapabilities().EXTENDED_PANEL_MENU) + { + var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); + } + return ret; +}; + + +/** + * Set context menu by XML string. + * + * Since 5.2.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + https://developer.chrome.com/extensions/contextMenus + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + * + * @param menu A XML string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setContextMenu = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); +}; + +/** + * Set context menu by JSON string. + * + * Since 6.0.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + https://developer.chrome.com/extensions/contextMenus + * + * @param menu A JSON string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu JSON: + * + * { + * "menu": [ + * { + * "id": "menuItemId1", + * "label": "testExample1", + * "enabled": true, + * "checkable": true, + * "checked": false, + * "icon": "./image/small_16X16.png" + * }, + * { + * "id": "menuItemId2", + * "label": "testExample2", + * "menu": [ + * { + * "id": "menuItemId2-1", + * "label": "testExample2-1", + * "menu": [ + * { + * "id": "menuItemId2-1-1", + * "label": "testExample2-1-1", + * "enabled": false, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "id": "menuItemId2-2", + * "label": "testExample2-2", + * "enabled": true, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "label": "---" + * }, + * { + * "id": "menuItemId3", + * "label": "testExample3", + * "enabled": false, + * "checkable": true, + * "checked": false + * } + * ] + * } + * + */ +CSInterface.prototype.setContextMenuByJSON = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); +}; + +/** + * Updates a context menu item by setting the enabled and selection status. + * + * Since 5.2.0 + * + * @param menuItemID The menu item ID. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + */ +CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) +{ + var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); +}; + +/** + * Get the visibility status of an extension window. + * + * Since 6.0.0 + * + * @return true if the extension window is visible; false if the extension window is hidden. + */ +CSInterface.prototype.isWindowVisible = function() +{ + return window.__adobe_cep__.invokeSync("isWindowVisible", ""); +}; + +/** + * Resize extension's content to the specified dimensions. + * 1. Works with modal and modeless extensions in all Adobe products. + * 2. Extension's manifest min/max size constraints apply and take precedence. + * 3. For panel extensions + * 3.1 This works in all Adobe products except: + * * Premiere Pro + * * Prelude + * * After Effects + * 3.2 When the panel is in certain states (especially when being docked), + * it will not change to the desired dimensions even when the + * specified size satisfies min/max constraints. + * + * Since 6.0.0 + * + * @param width The new width + * @param height The new height + */ +CSInterface.prototype.resizeContent = function(width, height) +{ + window.__adobe_cep__.resizeContent(width, height); +}; + +/** + * Register the invalid certificate callback for an extension. + * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. + * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. + * + * Since 6.1.0 + * + * @param callback the callback function + */ +CSInterface.prototype.registerInvalidCertificateCallback = function(callback) +{ + return window.__adobe_cep__.registerInvalidCertificateCallback(callback); +}; + +/** + * Register an interest in some key events to prevent them from being sent to the host application. + * + * This function works with modeless extensions and panel extensions. + * Generally all the key events will be sent to the host application for these two extensions if the current focused element + * is not text input or dropdown, + * If you want to intercept some key events and want them to be handled in the extension, please call this function + * in advance to prevent them being sent to the host application. + * + * Since 6.1.0 + * + * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or + an empty string will lead to removing the interest + * + * This JSON string should be an array, each object has following keys: + * + * keyCode: [Required] represents an OS system dependent virtual key code identifying + * the unmodified value of the pressed key. + * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. + * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. + * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. + * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. + * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. + * An example JSON string: + * + * [ + * { + * "keyCode": 48 + * }, + * { + * "keyCode": 123, + * "ctrlKey": true + * }, + * { + * "keyCode": 123, + * "ctrlKey": true, + * "metaKey": true + * } + * ] + * + */ +CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) +{ + return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); +}; + +/** + * Set the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @param title The window title. + */ +CSInterface.prototype.setWindowTitle = function(title) +{ + window.__adobe_cep__.invokeSync("setWindowTitle", title); +}; + +/** + * Get the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @return The window title. + */ +CSInterface.prototype.getWindowTitle = function() +{ + return window.__adobe_cep__.invokeSync("getWindowTitle", ""); +}; diff --git a/openpype/hosts/photoshop/api/extension/client/client.js b/openpype/hosts/photoshop/api/extension/client/client.js new file mode 100644 index 0000000000..f4ba4cfe47 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/client.js @@ -0,0 +1,300 @@ + // client facing part of extension, creates WSRPC client (jsx cannot + // do that) + // consumes RPC calls from server (OpenPype) calls ./host/index.jsx and + // returns values back (in json format) + + var logReturn = function(result){ log.warn('Result: ' + result);}; + + var csInterface = new CSInterface(); + + log.warn("script start"); + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + function myCallBack(){ + log.warn("Triggered index.jsx"); + } + // importing through manifest.xml isn't working because relative paths + // possibly TODO + jsx.evalFile('./host/index.jsx', myCallBack); + + function runEvalScript(script) { + // because of asynchronous nature of functions in jsx + // this waits for response + return new Promise(function(resolve, reject){ + csInterface.evalScript(script, resolve); + }); + } + + /** main entry point **/ + startUp("WEBSOCKET_URL"); + + // get websocket server url from environment value + async function startUp(url){ + log.warn("url", url); + promis = runEvalScript("getEnv('" + url + "')"); + + var res = await promis; + // run rest only after resolved promise + main(res); + } + + function get_extension_version(){ + /** Returns version number from extension manifest.xml **/ + log.debug("get_extension_version") + var path = csInterface.getSystemPath(SystemPath.EXTENSION); + log.debug("extension path " + path); + + var result = window.cep.fs.readFile(path + "/CSXS/manifest.xml"); + var version = undefined; + if(result.err === 0){ + if (window.DOMParser) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(result.data.toString(), 'text/xml'); + const children = xmlDoc.children; + + for (let i = 0; i <= children.length; i++) { + if (children[i] && children[i].getAttribute('ExtensionBundleVersion')) { + version = children[i].getAttribute('ExtensionBundleVersion'); + } + } + } + } + return version + } + + function main(websocket_url){ + // creates connection to 'websocket_url', registers routes + log.warn("websocket_url", websocket_url); + var default_url = 'ws://localhost:8099/ws/'; + + if (websocket_url == ''){ + websocket_url = default_url; + } + log.warn("connecting to:", websocket_url); + RPC = new WSRPC(websocket_url, 5000); // spin connection + + RPC.connect(); + + log.warn("connected"); + + function EscapeStringForJSX(str){ + // Replaces: + // \ with \\ + // ' with \' + // " with \" + // See: https://stackoverflow.com/a/3967927/5285364 + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + + RPC.addRoute('Photoshop.open', function (data) { + log.warn('Server called client route "open":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("fileOpen('" + escapedPath +"')") + .then(function(result){ + log.warn("open: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.read', function (data) { + log.warn('Server called client route "read":', data); + return runEvalScript("getHeadline()") + .then(function(result){ + log.warn("getHeadline: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_layers', function (data) { + log.warn('Server called client route "get_layers":', data); + return runEvalScript("getLayers()") + .then(function(result){ + log.warn("getLayers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.set_visible', function (data) { + log.warn('Server called client route "set_visible":', data); + return runEvalScript("setVisible(" + data.layer_id + ", " + + data.visibility + ")") + .then(function(result){ + log.warn("setVisible: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_name', function (data) { + log.warn('Server called client route "get_active_document_name":', + data); + return runEvalScript("getActiveDocumentName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_full_name', function (data) { + log.warn('Server called client route ' + + '"get_active_document_full_name":', data); + return runEvalScript("getActiveDocumentFullName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.save', function (data) { + log.warn('Server called client route "save":', data); + + return runEvalScript("save()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_selected_layers', function (data) { + log.warn('Server called client route "get_selected_layers":', data); + + return runEvalScript("getSelectedLayers()") + .then(function(result){ + log.warn("get_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.create_group', function (data) { + log.warn('Server called client route "create_group":', data); + + return runEvalScript("createGroup('" + data.name + "')") + .then(function(result){ + log.warn("createGroup: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.group_selected_layers', function (data) { + log.warn('Server called client route "group_selected_layers":', + data); + + return runEvalScript("groupSelectedLayers(null, "+ + "'" + data.name +"')") + .then(function(result){ + log.warn("group_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.import_smart_object', function (data) { + log.warn('Server called client "import_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("importSmartObject('" + escapedPath +"', " + + "'"+ data.name +"',"+ + + data.as_reference +")") + .then(function(result){ + log.warn("import_smart_object: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.replace_smart_object', function (data) { + log.warn('Server called route "replace_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("replaceSmartObjects("+data.layer_id+"," + + "'" + escapedPath +"',"+ + "'"+ data.name +"')") + .then(function(result){ + log.warn("replaceSmartObjects: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.delete_layer', function (data) { + log.warn('Server called route "delete_layer":', data); + return runEvalScript("deleteLayer("+data.layer_id+")") + .then(function(result){ + log.warn("delete_layer: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.rename_layer', function (data) { + log.warn('Server called route "rename_layer":', data); + return runEvalScript("renameLayer("+data.layer_id+", " + + "'"+ data.name +"')") + .then(function(result){ + log.warn("rename_layer: " + result); + return result; + }); +}); + + RPC.addRoute('Photoshop.select_layers', function (data) { + log.warn('Server called client route "select_layers":', data); + + return runEvalScript("selectLayers('" + data.layers +"')") + .then(function(result){ + log.warn("select_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.is_saved', function (data) { + log.warn('Server called client route "is_saved":', data); + + return runEvalScript("isSaved()") + .then(function(result){ + log.warn("is_saved: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.saveAs', function (data) { + log.warn('Server called client route "saveAsJPEG":', data); + var escapedPath = EscapeStringForJSX(data.image_path); + return runEvalScript("saveAs('" + escapedPath + "', " + + "'" + data.ext + "', " + + data.as_copy + ")") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.imprint', function (data) { + log.warn('Server called client route "imprint":', data); + var escaped = data.payload.replace(/\n/g, "\\n"); + return runEvalScript("imprint('" + escaped + "')") + .then(function(result){ + log.warn("imprint: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_extension_version', function (data) { + log.warn('Server called client route "get_extension_version":', data); + return get_extension_version(); + }); + + RPC.addRoute('Photoshop.close', function (data) { + log.warn('Server called client route "close":', data); + return runEvalScript("close()"); + }); + + RPC.call('Photoshop.ping').then(function (data) { + log.warn('Result for calling server route "ping": ', data); + return runEvalScript("ping()") + .then(function(result){ + log.warn("ping: " + result); + return result; + }); + + }, function (error) { + log.warn(error); + }); + + } + + log.warn("end script"); diff --git a/openpype/hosts/photoshop/api/extension/client/loglevel.min.js b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js new file mode 100644 index 0000000000..648d7e9ff6 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js @@ -0,0 +1,2 @@ +/*! loglevel - v1.6.8 - https://github.com/pimterry/loglevel - (c) 2020 Tim Perry - licensed MIT */ +!function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.log=b()}(this,function(){"use strict";function a(a,b){var c=a[b];if("function"==typeof c.bind)return c.bind(a);try{return Function.prototype.bind.call(c,a)}catch(b){return function(){return Function.prototype.apply.apply(c,[a,arguments])}}}function b(){console.log&&(console.log.apply?console.log.apply(console,arguments):Function.prototype.apply.apply(console.log,[console,arguments])),console.trace&&console.trace()}function c(c){return"debug"===c&&(c="log"),typeof console!==i&&("trace"===c&&j?b:void 0!==console[c]?a(console,c):void 0!==console.log?a(console,"log"):h)}function d(a,b){for(var c=0;c=0&&b<=j.levels.SILENT))throw"log.setLevel() called with invalid level: "+b;if(h=b,!1!==c&&e(b),d.call(j,b,a),typeof console===i&&b 1 && arguments[1] !== undefined ? arguments[1] : 1000; + + _classCallCheck(this, WSRPC); + + var self = this; + URL = getAbsoluteWsUrl(URL); + self.id = 1; + self.eventId = 0; + self.socketStarted = false; + self.eventStore = { + onconnect: {}, + onerror: {}, + onclose: {}, + onchange: {} + }; + self.connectionNumber = 0; + self.oneTimeEventStore = { + onconnect: [], + onerror: [], + onclose: [], + onchange: [] + }; + self.callQueue = []; + + function createSocket() { + var ws = new WebSocket(URL); + + var rejectQueue = function rejectQueue() { + self.connectionNumber++; // rejects incoming calls + + var deferred; //reject all pending calls + + while (0 < self.callQueue.length) { + var callObj = self.callQueue.shift(); + deferred = self.store[callObj.id]; + delete self.store[callObj.id]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } // reject all from the store + + + for (var key in self.store) { + if (!self.store.hasOwnProperty(key)) continue; + deferred = self.store[key]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } + }; + + function reconnect(callEvents) { + setTimeout(function () { + try { + self.socket = createSocket(); + self.id = 1; + } catch (exc) { + callEvents('onerror', exc); + delete self.socket; + console.error(exc); + } + }, reconnectTimeout); + } + + ws.onclose = function (err) { + log('ONCLOSE CALLED', 'STATE', self.public.state()); + trace(err); + + for (var serial in self.store) { + if (!self.store.hasOwnProperty(serial)) continue; + + if (self.store[serial].hasOwnProperty('reject')) { + self.store[serial].reject('Connection closed'); + } + } + + rejectQueue(); + callEvents('onclose', err); + callEvents('onchange', err); + reconnect(callEvents); + }; + + ws.onerror = function (err) { + log('ONERROR CALLED', 'STATE', self.public.state()); + trace(err); + rejectQueue(); + callEvents('onerror', err); + callEvents('onchange', err); + log('WebSocket has been closed by error: ', err); + }; + + function tryCallEvent(func, event) { + try { + return func(event); + } catch (e) { + if (e.hasOwnProperty('stack')) { + log(e.stack); + } else { + log('Event function', func, 'raised unknown error:', e); + } + + console.error(e); + } + } + + function callEvents(evName, event) { + while (0 < self.oneTimeEventStore[evName].length) { + var deferred = self.oneTimeEventStore[evName].shift(); + if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); + } + + for (var i in self.eventStore[evName]) { + if (!self.eventStore[evName].hasOwnProperty(i)) continue; + var cur = self.eventStore[evName][i]; + tryCallEvent(cur, event); + } + } + + ws.onopen = function (ev) { + log('ONOPEN CALLED', 'STATE', self.public.state()); + trace(ev); + + while (0 < self.callQueue.length) { + // noinspection JSUnresolvedFunction + self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); + } + + callEvents('onconnect', ev); + callEvents('onchange', ev); + }; + + function handleCall(self, data) { + if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); + var connectionNumber = self.connectionNumber; + var deferred = new Deferred(); + deferred.promise.then(function (result) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + result: result + })); + }, function (error) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + error: error + })); + }); + var func = self.routes[data.method]; + if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); + + function badPromise() { + throw new Error("You should register route with async flag."); + } + + var promiseMock = { + resolve: badPromise, + reject: badPromise + }; + + try { + deferred.resolve(func.apply(promiseMock, [data.params])); + } catch (e) { + deferred.reject(e); + console.error(e); + } + } + + function handleError(self, data) { + if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + log('REJECTING', data.error); + deferred.reject(data.error); + } + + function handleResult(self, data) { + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + + if (data.hasOwnProperty('result')) { + return deferred.resolve(data.result); + } + + return deferred.reject(data.error); + } + + ws.onmessage = function (message) { + log('ONMESSAGE CALLED', 'STATE', self.public.state()); + trace(message); + if (message.type !== 'message') return; + var data; + + try { + data = JSON.parse(message.data); + log(data); + + if (data.hasOwnProperty('method')) { + return handleCall(self, data); + } else if (data.hasOwnProperty('error') && data.error === null) { + return handleError(self, data); + } else { + return handleResult(self, data); + } + } catch (exception) { + var err = { + error: exception.message, + result: null, + id: data ? data.id : null + }; + self.socket.send(JSON.stringify(err)); + console.error(exception); + } + }; + + return ws; + } + + function makeCall(func, args, params) { + self.id += 2; + var deferred = new Deferred(); + var callObj = Object.freeze({ + id: self.id, + method: func, + params: args + }); + var state = self.public.state(); + + if (state === 'OPEN') { + self.store[self.id] = deferred; + self.socket.send(JSON.stringify(callObj)); + } else if (state === 'CONNECTING') { + log('SOCKET IS', state); + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } else { + log('SOCKET IS', state); + + if (params && params['noWait']) { + deferred.reject("Socket is: ".concat(state)); + } else { + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } + } + + return deferred.promise; + } + + self.asyncRoutes = {}; + self.routes = {}; + self.store = {}; + self.public = Object.freeze({ + call: function call(func, args, params) { + return makeCall(func, args, params); + }, + addRoute: function addRoute(route, callback, isAsync) { + self.asyncRoutes[route] = isAsync || false; + self.routes[route] = callback; + }, + deleteRoute: function deleteRoute(route) { + delete self.asyncRoutes[route]; + return delete self.routes[route]; + }, + addEventListener: function addEventListener(event, func) { + var eventId = self.eventId++; + self.eventStore[event][eventId] = func; + return eventId; + }, + removeEventListener: function removeEventListener(event, index) { + if (self.eventStore[event].hasOwnProperty(index)) { + delete self.eventStore[event][index]; + return true; + } else { + return false; + } + }, + onEvent: function onEvent(event) { + var deferred = new Deferred(); + self.oneTimeEventStore[event].push(deferred); + return deferred.promise; + }, + destroy: function destroy() { + return self.socket.close(); + }, + state: function state() { + return readyState[this.stateCode()]; + }, + stateCode: function stateCode() { + if (self.socketStarted && self.socket) return self.socket.readyState; + return 3; + }, + connect: function connect() { + self.socketStarted = true; + self.socket = createSocket(); + } + }); + self.public.addRoute('log', function (argsObj) { + //console.info("Websocket sent: ".concat(argsObj)); + }); + self.public.addRoute('ping', function (data) { + return data; + }); + return self.public; + }; + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + return WSRPC; + +})); +//# sourceMappingURL=wsrpc.js.map diff --git a/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js new file mode 100644 index 0000000000..f1264b91c4 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js @@ -0,0 +1 @@ +!function(global,factory){"object"==typeof exports&&"undefined"!=typeof module?module.exports=factory():"function"==typeof define&&define.amd?define(factory):(global=global||self).WSRPC=factory()}(this,function(){"use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function Deferred(){_classCallCheck(this,Deferred);var self=this;function wrapper(func){return function(){if(!self.done)return self.done=!0,func.apply(this,arguments);console.error(new Error("Promise already done"))}}return self.resolve=null,self.reject=null,self.done=!1,self.promise=new Promise(function(resolve,reject){self.resolve=wrapper(resolve),self.reject=wrapper(reject)}),self.promise.isPending=function(){return!self.done},self}function logGroup(group,level,args){console.group(group),console[level].apply(this,args),console.groupEnd()}function log(){WSRPC.DEBUG&&logGroup("WSRPC.DEBUG","trace",arguments)}function trace(msg){if(WSRPC.TRACE){var payload=msg;"data"in msg&&(payload=JSON.parse(msg.data)),logGroup("WSRPC.TRACE","trace",[payload])}}var readyState=Object.freeze({0:"CONNECTING",1:"OPEN",2:"CLOSING",3:"CLOSED"}),WSRPC=function WSRPC(URL){var reconnectTimeout=1 // +// forceEval is now by default true // +// It wraps the scripts in a try catch and an eval providing useful error handling // +// One can set in the jsx engine $.includeStack = true to return the call stack in the event of an error // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/////////////////////////////////////////////////////////////////////////////////////////////////////////// +// JSX.js for calling jsx code from the js engine // +// 2 methods included // +// 1) jsx.evalScript AKA jsx.eval // +// 2) jsx.evalFile AKA jsx.file // +// Special features // +// 1) Allows all changes in your jsx code to be reloaded into your extension at the click of a button // +// 2) Can enable the $.fileName property to work and provides a $.__fileName() method as an alternative // +// 3) Can force a callBack result from InDesign // +// 4) No more csInterface.evalScript('alert("hello "' + title + " " + name + '");') // +// use jsx.evalScript('alert("hello __title__ __name__");', {title: title, name: name}); // +// 5) execute jsx files from your jsx folder like this jsx.evalFile('myFabJsxScript.jsx'); // +// or from a relative path jsx.evalFile('../myFabScripts/myFabJsxScript.jsx'); // +// or from an absolute url jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) // +// or from an absolute url jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) // +// 6) Parameter can be entered in the from of a parameter list which can be in any order or as an object // +// 7) Not camelCase sensitive (very useful for the illiterate) // +// Dead easy to use BUT SPEND THE 3 TO 5 MINUTES IT SHOULD TAKE TO READ THE INSTRUCTIONS // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/* jshint undef:true, unused:true, esversion:6 */ + +////////////////////////////////////// +// jsx is the interface for the API // +////////////////////////////////////// + +var jsx; + +// Wrap everything in an anonymous function to prevent leeks +(function() { + ///////////////////////////////////////////////////////////////////// + // Substitute some CSInterface functions to avoid dependency on it // + ///////////////////////////////////////////////////////////////////// + + var __dirname = (function() { + var path, isMac; + path = decodeURI(window.__adobe_cep__.getSystemPath('extension')); + isMac = navigator.platform[0] === 'M'; // [M]ac + path = path.replace('file://' + (isMac ? '' : '/'), ''); + return path; + })(); + + var evalScript = function(script, callback) { + callback = callback || function() {}; + window.__adobe_cep__.evalScript(script, callback); + }; + + + //////////////////////////////////////////// + // In place of using the node path module // + //////////////////////////////////////////// + + // jshint undef: true, unused: true + + // A very minified version of the NodeJs Path module!! + // For use outside of NodeJs + // Majorly nicked by Trevor from Joyent + var path = (function() { + + var isString = function(arg) { + return typeof arg === 'string'; + }; + + // var isObject = function(arg) { + // return typeof arg === 'object' && arg !== null; + // }; + + var basename = function(path) { + if (!isString(path)) { + throw new TypeError('Argument to path.basename must be a string'); + } + var bits = path.split(/[\/\\]/g); + return bits[bits.length - 1]; + }; + + // jshint undef: true + // Regex to split a windows path into three parts: [*, device, slash, + // tail] windows-only + var splitDeviceRe = + /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; + + // Regex to split the tail part of the above into [*, dir, basename, ext] + // var splitTailRe = + // /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; + + var win32 = {}; + // Function to split a filename into [root, dir, basename, ext] + // var win32SplitPath = function(filename) { + // // Separate device+slash from tail + // var result = splitDeviceRe.exec(filename), + // device = (result[1] || '') + (result[2] || ''), + // tail = result[3] || ''; + // // Split the tail into dir, basename and extension + // var result2 = splitTailRe.exec(tail), + // dir = result2[1], + // basename = result2[2], + // ext = result2[3]; + // return [device, dir, basename, ext]; + // }; + + var win32StatPath = function(path) { + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = !!device && device[1] !== ':'; + return { + device: device, + isUnc: isUnc, + isAbsolute: isUnc || !!result[2], // UNC paths are always absolute + tail: result[3] + }; + }; + + var normalizeUNCRoot = function(device) { + return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\'); + }; + + var normalizeArray = function(parts, allowAboveRoot) { + var res = []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + + // ignore empty parts + if (!p || p === '.') + continue; + + if (p === '..') { + if (res.length && res[res.length - 1] !== '..') { + res.pop(); + } else if (allowAboveRoot) { + res.push('..'); + } + } else { + res.push(p); + } + } + + return res; + }; + + win32.normalize = function(path) { + var result = win32StatPath(path), + device = result.device, + isUnc = result.isUnc, + isAbsolute = result.isAbsolute, + tail = result.tail, + trailingSlash = /[\\\/]$/.test(tail); + + // Normalize the tail path + tail = normalizeArray(tail.split(/[\\\/]+/), !isAbsolute).join('\\'); + + if (!tail && !isAbsolute) { + tail = '.'; + } + if (tail && trailingSlash) { + tail += '\\'; + } + + // Convert slashes to backslashes when `device` points to an UNC root. + // Also squash multiple slashes into a single one where appropriate. + if (isUnc) { + device = normalizeUNCRoot(device); + } + + return device + (isAbsolute ? '\\' : '') + tail; + }; + win32.join = function() { + var paths = []; + for (var i = 0; i < arguments.length; i++) { + var arg = arguments[i]; + if (!isString(arg)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (arg) { + paths.push(arg); + } + } + + var joined = paths.join('\\'); + + // Make sure that the joined path doesn't start with two slashes, because + // normalize() will mistake it for an UNC path then. + // + // This step is skipped when it is very clear that the user actually + // intended to point at an UNC path. This is assumed when the first + // non-empty string arguments starts with exactly two slashes followed by + // at least one more non-slash character. + // + // Note that for normalize() to treat a path as an UNC path it needs to + // have at least 2 components, so we don't filter for that here. + // This means that the user can use join to construct UNC paths from + // a server name and a share name; for example: + // path.join('//server', 'share') -> '\\\\server\\share\') + if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) { + joined = joined.replace(/^[\\\/]{2,}/, '\\'); + } + return win32.normalize(joined); + }; + + var posix = {}; + + // posix version + posix.join = function() { + var path = ''; + for (var i = 0; i < arguments.length; i++) { + var segment = arguments[i]; + if (!isString(segment)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (segment) { + if (!path) { + path += segment; + } else { + path += '/' + segment; + } + } + } + return posix.normalize(path); + }; + + // path.normalize(path) + // posix version + posix.normalize = function(path) { + var isAbsolute = path.charAt(0) === '/', + trailingSlash = path && path[path.length - 1] === '/'; + + // Normalize the path + path = normalizeArray(path.split('/'), !isAbsolute).join('/'); + + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isAbsolute ? '/' : '') + path; + }; + + win32.basename = posix.basename = basename; + + this.win32 = win32; + this.posix = posix; + return (navigator.platform[0] === 'M') ? posix : win32; + })(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // The is the "main" function which is to be prototyped // + // It run a small snippet in the jsx engine that // + // 1) Assigns $.__dirname with the value of the extensions __dirname base path // + // 2) Sets up a method $.__fileName() for retrieving from within the jsx script it's $.fileName value // + // more on that method later // + // At the end of the script the global declaration jsx = new Jsx(); has been made. // + // If you like you can remove that and include in your relevant functions // + // var jsx = new Jsx(); You would never call the Jsx function without the "new" declaration // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + var Jsx = function() { + var jsxScript; + // Setup jsx function to enable the jsx scripts to easily retrieve their file location + jsxScript = [ + '$.level = 0;', + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}' + ].join(''); + evalScript(jsxScript); + return this; + }; + + /** + * [evalScript] For calling jsx scripts from the js engine + * + * The jsx.evalScript method is used for calling jsx scripts directly from the js engine + * Allows for easy replacement i.e. variable insertions and for forcing eval. + * For convenience jsx.eval or jsx.script or jsx.evalscript can be used instead of calling jsx.evalScript + * + * @param {String} jsxScript + * The string that makes up the jsx script + * it can contain a simple template like syntax for replacements + * 'alert("__foo__");' + * the __foo__ will be replaced as per the replacements parameter + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * given the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: true + * }); + * note that either lower or camelCase key names are valid + * i.e. both callback or callBack will work + * + * The following keys are the same jsx || script || jsxScript || jsxscript || file + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * The following keys are the same forceEvalScript || forceevalscript || evalScript || evalscript; + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalScript = function() { + var arg, i, key, replaceThis, withThis, args, callback, forceEval, replacements, jsxScript, isBin; + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + args = arguments; + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 4; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor === String) { + jsxScript = arg; + continue; + } + if (arg.constructor === Object) { + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + // Have changed the forceEval default to be true as I prefer the error handling + if (forceEval !== false) { + forceEval = true; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // On Illustrator and other apps the result of the jsx script is automatically passed as a string // + // if you have a "script" containing the single number 1 and nothing else then the callBack will register as "1" // + // On InDesign that same script will provide a blank callBack // + // Let's say we have a callBack function var callBack = function(result){alert(result);} // + // On Ai your see the 1 in the alert // + // On ID your just see a blank alert // + // To see the 1 in the alert you need to convert the result to a string and then it will show // + // So if we rewrite out 1 byte script to '1' i.e. surround the 1 in quotes then the call back alert will show 1 // + // If the scripts planed one can make sure that the results always passed as a string (including errors) // + // otherwise one can wrap the script in an eval and then have the result passed as a string // + // I have not gone through all the apps but can say // + // for Ai you never need to set the forceEval to true // + // for ID you if you have not coded your script appropriately and your want to send a result to the callBack then set forceEval to true // + // I changed this that even on Illustrator it applies the try catch, Note the try catch will fail if $.level is set to 1 // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + if (forceEval) { + + isBin = (jsxScript.substring(0, 10) === '@JSXBIN@ES') ? '' : '\n'; + jsxScript = ( + // "\n''') + '';} catch(e){(function(e){var n, a=[]; for (n in e){a.push(n + ': ' + e[n])}; return a.join('\n')})(e)}"); + // "\n''') + '';} catch(e){e + (e.line ? ('\\nLine ' + (+e.line - 1)) : '')}"); + [ + "$.level = 0;", + "try{eval('''" + isBin, // need to add an extra line otherwise #targetengine doesn't work ;-] + jsxScript.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"') + "\n''') + '';", + "} catch (e) {", + " (function(e) {", + " var line, sourceLine, name, description, ErrorMessage, fileName, start, end, bug;", + " line = +e.line" + (isBin === '' ? ';' : ' - 1;'), // To take into account the extra line added + " fileName = File(e.fileName).fsName;", + " sourceLine = line && e.source.split(/[\\r\\n]/)[line];", + " name = e.name;", + " description = e.description;", + " ErrorMessage = name + ' ' + e.number + ': ' + description;", + " if (fileName.length && !(/[\\/\\\\]\\d+$/.test(fileName))) {", + " ErrorMessage += '\\nFile: ' + fileName;", + " line++;", + " }", + " if (line){", + " ErrorMessage += '\\nLine: ' + line +", + " '-> ' + ((sourceLine.length < 300) ? sourceLine : sourceLine.substring(0,300) + '...');", + " }", + " if (e.start) {ErrorMessage += '\\nBug: ' + e.source.substring(e.start - 1, e.end)}", + " if ($.includeStack) {ErrorMessage += '\\nStack:' + $.stack;}", + " return ErrorMessage;", + " })(e);", + "}" + ].join('') + ); + + } + + ///////////////////////////////////////////////////////////// + // deal with the replacements // + // Note it's probably better to use ${template} `literals` // + ///////////////////////////////////////////////////////////// + + if (replacements) { + for (key in replacements) { + if (replacements.hasOwnProperty(key)) { + replaceThis = new RegExp('__' + key + '__', 'g'); + withThis = replacements[key]; + jsxScript = jsxScript.replace(replaceThis, withThis + ''); + } + } + } + + + try { + evalScript(jsxScript, callback); + return true; + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + }; + + + /** + * [evalFile] For calling jsx scripts from the js engine + * + * The jsx.evalFiles method is used for executing saved jsx scripts + * where the jsxScript parameter is a string of the jsx scripts file location. + * For convenience jsx.file or jsx.evalfile can be used instead of jsx.evalFile + * + * @param {String} file + * The path to jsx script + * If only the base name is provided then the path will be presumed to be the + * To execute files stored in the jsx folder located in the __dirname folder use + * jsx.evalFile('myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located in the __dirname folder use + * jsx.evalFile('./myFabScripts/myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located at an absolute url use + * jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) + * or jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * give the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * By default when possible the forceEvalScript will be set to true + * The forceEvalScript option cannot be true when there are replacements + * To force the forceEvalScript to be false you can send a blank set of replacements + * jsx.evalFile('myFabScript.jsx', {}); Will NOT be executed using the $.evalScript method + * jsx.evalFile('myFabScript.jsx'); Will YES be executed using the $.evalScript method + * see the forceEvalScript parameter for details on this + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * If no replacements are needed then the jsx script is be executed by using the $.evalFile method + * This exposes the true value of the $.fileName property + * In such a case it's best to avoid using the $.__fileName() with no base name as it won't work + * BUT one can still use the $.__fileName('baseName') method which is more accurate than the standard $.fileName property + * Let's say you have a Drive called "Graphics" AND YOU HAVE a root folder on your "main" drive called "Graphics" + * You call a script jsx.evalFile('/Volumes/Graphics/myFabScript.jsx'); + * $.fileName will give you '/Graphics/myFabScript.jsx' which is wrong + * $.__fileName('myFabScript.jsx') will give you '/Volumes/Graphics/myFabScript.jsx' which is correct + * $.__fileName() will not give you a reliable result + * Note that if your calling multiple versions of myFabScript.jsx stored in multiple folders then you can get stuffed! + * i.e. if the fileName is important to you then don't do that. + * It also will force the result of the jsx file as a string which is particularly useful for InDesign callBacks + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: false, + * }); + * note that either lower or camelCase key names or valid + * i.e. both callback or callBack will work + * + * The following keys are the same file || jsx || script || jsxScript || jsxscript + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalFile = function() { + var arg, args, callback, fileName, fileNameScript, forceEval, forceEvalScript, + i, jsxFolder, jsxScript, newLine, replacements, success; + + success = true; // optimistic + args = arguments; + + jsxFolder = path.join(__dirname, 'jsx'); + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // $.fileName does not return it's correct path in the jsx engine for files called from the js engine // + // In Illustrator it returns an integer in InDesign it returns an empty string // + // This script injection allows for the script to know it's path by calling // + // $.__fileName(); // + // on Illustrator this works pretty well // + // on InDesign it's best to use with a bit of care // + // If the a second script has been called the InDesing will "forget" the path to the first script // + // 2 work-arounds for this // + // 1) at the beginning of your script add var thePathToMeIs = $.fileName(); // + // thePathToMeIs will not be forgotten after running the second script // + // 2) $.__fileName('myBaseName.jsx'); // + // for example you have file with the following path // + // /path/to/me.jsx // + // Call $.__fileName('me.jsx') and you will get /path/to/me.jsx even after executing a second script // + // Note When the forceEvalScript option is used then you just use the regular $.fileName property // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + fileNameScript = [ + // The if statement should not normally be executed + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}', + '$.__fileNames["__basename__"] = $.__fileNames["" + $.fileName] = "__fileName__";' + ].join(''); + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 5; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor.name === 'String') { + jsxScript = arg; + continue; + } + if (arg.constructor.name === 'Object') { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // If no replacements are provided then the $.evalScript method will be used // + // This will allow directly for the $.fileName property to be used // + // If one does not want the $.evalScript method to be used then // + // either send a blank object as the replacements {} // + // or explicitly set the forceEvalScript option to false // + // This can only be done if the parameters are passed as an object // + // i.e. jsx.evalFile({file:'myFabScript.jsx', forceEvalScript: false}); // + // if the file was called using // + // i.e. jsx.evalFile('myFabScript.jsx'); // + // then the following jsx code is called $.evalFile(new File('Path/to/myFabScript.jsx', 10000000000)) + ''; // + // forceEval is never needed if the forceEvalScript is triggered // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + forceEvalScript = !replacements; + + + ////////////////////////////////////////////////////// + // Get path of script // + // Check if it's literal, relative or in jsx folder // + ////////////////////////////////////////////////////// + + if (/^\/|[a-zA-Z]+:/.test(jsxScript)) { // absolute path Mac | Windows + jsxScript = path.normalize(jsxScript); + } else if (/^\.+\//.test(jsxScript)) { + jsxScript = path.join(__dirname, jsxScript); // relative path + } else { + jsxScript = path.join(jsxFolder, jsxScript); // files in the jsxFolder + } + + if (forceEvalScript) { + jsxScript = jsxScript.replace(/"/g, '\\"'); + // Check that the path exist, should change this to asynchronous at some point + if (!window.cep.fs.stat(jsxScript).err) { + jsxScript = fileNameScript.replace(/__fileName__/, jsxScript).replace(/__basename__/, path.basename(jsxScript)) + + '$.evalFile(new File("' + jsxScript.replace(/\\/g, '\\\\') + '")) + "";'; + return this.evalScript(jsxScript, callback, forceEval); + } else { + throw new Error(`The file: {jsxScript} could not be found / read`); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Replacements made so we can't use $.evalFile and need to read the jsx script for ourselves // + //////////////////////////////////////////////////////////////////////////////////////////////// + + fileName = jsxScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + try { + jsxScript = window.cep.fs.readFile(jsxScript).data; + } catch (er) { + throw new Error(`The file: ${fileName} could not be read`); + } + // It is desirable that the injected fileNameScript is on the same line as the 1st line of the script + // This is so that the $.line or error.line returns the same value as the actual file + // However if the 1st line contains a # directive then we need to insert a new line and stuff the above problem + // When possible i.e. when there's no replacements then $.evalFile will be used and then the whole issue is avoided + newLine = /^\s*#/.test(jsxScript) ? '\n' : ''; + jsxScript = fileNameScript.replace(/__fileName__/, fileName).replace(/__basename__/, path.basename(fileName)) + newLine + jsxScript; + + try { + // evalScript(jsxScript, callback); + return this.evalScript(jsxScript, callback, replacements, forceEval); + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + return success; // success should be an array but for now it's a Boolean + }; + + + //////////////////////////////////// + // Setup alternative method names // + //////////////////////////////////// + Jsx.prototype.eval = Jsx.prototype.script = Jsx.prototype.evalscript = Jsx.prototype.evalScript; + Jsx.prototype.file = Jsx.prototype.evalfile = Jsx.prototype.evalFile; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Examples // + // jsx.evalScript('alert("foo");'); // + // jsx.evalFile('foo.jsx'); // where foo.jsx is stored in the jsx folder at the base of the extensions directory // + // jsx.evalFile('../myFolder/foo.jsx'); // where a relative or absolute file path is given // + // // + // using conventional methods one would use in the case were the values to swap were supplied by variables // + // csInterface.evalScript('var q = "' + name + '"; alert("' + myString + '" ' + myOp + ' q);q;', callback); // + // Using all the '' + foo + '' is very error prone // + // jsx.evalScript('var q = "__name__"; alert(__string__ __opp__ q);q;',{'name':'Fred', 'string':'Hello ', 'opp':'+'}, callBack); // + // is much simpler and less error prone // + // // + // more readable to use object // + // jsx.evalFile({ // + // file: 'yetAnotherFabScript.jsx', // + // replacements: {"this": foo, That: bar, and: "&&", the: foo2, other: bar2}, // + // eval: true // + // }) // + // Enjoy // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + + jsx = new Jsx(); +})(); diff --git a/openpype/hosts/photoshop/api/extension/host/index.jsx b/openpype/hosts/photoshop/api/extension/host/index.jsx new file mode 100644 index 0000000000..2acec1ebc1 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/index.jsx @@ -0,0 +1,484 @@ +#include "json.js"; +#target photoshop + +var LogFactory=function(file,write,store,level,defaultStatus,continuing){if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}else if(!file)file={file:{}};write=(file.write!==undefined)?file.write:write;if(write===undefined){write=true;}store=(file.store!==undefined)?file.store||false:store||false;level=(file.level!==undefined)?file.level:level;defaultStatus=(file.defaultStatus!==undefined)?file.defaultStatus:defaultStatus;if(defaultStatus===undefined){defaultStatus='LOG';}continuing=(file.continuing!==undefined)?file.continuing:continuing||false;file=file.file||{};var stack,times,logTime,logPoint,icons,statuses,LOG_LEVEL,LOG_STATUS;stack=[];times=[];logTime=new Date();logPoint='Log Factory Start';icons={"1":"\ud83d\udd50","130":"\ud83d\udd5c","2":"\ud83d\udd51","230":"\ud83d\udd5d","3":"\ud83d\udd52","330":"\ud83d\udd5e","4":"\ud83d\udd53","430":"\ud83d\udd5f","5":"\ud83d\udd54","530":"\ud83d\udd60","6":"\ud83d\udd55","630":"\ud83d\udd61","7":"\ud83d\udd56","730":"\ud83d\udd62","8":"\ud83d\udd57","830":"\ud83d\udd63","9":"\ud83d\udd58","930":"\ud83d\udd64","10":"\ud83d\udd59","1030":"\ud83d\udd65","11":"\ud83d\udd5a","1130":"\ud83d\udd66","12":"\ud83d\udd5b","1230":"\ud83d\udd67","AIRPLANE":"\ud83d\udee9","ALARM":"\u23f0","AMBULANCE":"\ud83d\ude91","ANCHOR":"\u2693","ANGRY":"\ud83d\ude20","ANGUISHED":"\ud83d\ude27","ANT":"\ud83d\udc1c","ANTENNA":"\ud83d\udce1","APPLE":"\ud83c\udf4f","APPLE2":"\ud83c\udf4e","ATM":"\ud83c\udfe7","ATOM":"\u269b","BABYBOTTLE":"\ud83c\udf7c","BAD:":"\ud83d\udc4e","BANANA":"\ud83c\udf4c","BANDAGE":"\ud83e\udd15","BANK":"\ud83c\udfe6","BATTERY":"\ud83d\udd0b","BED":"\ud83d\udecf","BEE":"\ud83d\udc1d","BEER":"\ud83c\udf7a","BELL":"\ud83d\udd14","BELLOFF":"\ud83d\udd15","BIRD":"\ud83d\udc26","BLACKFLAG":"\ud83c\udff4","BLUSH":"\ud83d\ude0a","BOMB":"\ud83d\udca3","BOOK":"\ud83d\udcd5","BOOKMARK":"\ud83d\udd16","BOOKS":"\ud83d\udcda","BOW":"\ud83c\udff9","BOWLING":"\ud83c\udfb3","BRIEFCASE":"\ud83d\udcbc","BROKEN":"\ud83d\udc94","BUG":"\ud83d\udc1b","BUILDING":"\ud83c\udfdb","BUILDINGS":"\ud83c\udfd8","BULB":"\ud83d\udca1","BUS":"\ud83d\ude8c","CACTUS":"\ud83c\udf35","CALENDAR":"\ud83d\udcc5","CAMEL":"\ud83d\udc2a","CAMERA":"\ud83d\udcf7","CANDLE":"\ud83d\udd6f","CAR":"\ud83d\ude98","CAROUSEL":"\ud83c\udfa0","CASTLE":"\ud83c\udff0","CATEYES":"\ud83d\ude3b","CATJOY":"\ud83d\ude39","CATMOUTH":"\ud83d\ude3a","CATSMILE":"\ud83d\ude3c","CD":"\ud83d\udcbf","CHECK":"\u2714","CHEQFLAG":"\ud83c\udfc1","CHICK":"\ud83d\udc25","CHICKEN":"\ud83d\udc14","CHICKHEAD":"\ud83d\udc24","CIRCLEBLACK":"\u26ab","CIRCLEBLUE":"\ud83d\udd35","CIRCLERED":"\ud83d\udd34","CIRCLEWHITE":"\u26aa","CIRCUS":"\ud83c\udfaa","CLAPPER":"\ud83c\udfac","CLAPPING":"\ud83d\udc4f","CLIP":"\ud83d\udcce","CLIPBOARD":"\ud83d\udccb","CLOUD":"\ud83c\udf28","CLOVER":"\ud83c\udf40","CLOWN":"\ud83e\udd21","COLDSWEAT":"\ud83d\ude13","COLDSWEAT2":"\ud83d\ude30","COMPRESS":"\ud83d\udddc","CONFOUNDED":"\ud83d\ude16","CONFUSED":"\ud83d\ude15","CONSTRUCTION":"\ud83d\udea7","CONTROL":"\ud83c\udf9b","COOKIE":"\ud83c\udf6a","COOKING":"\ud83c\udf73","COOL":"\ud83d\ude0e","COOLBOX":"\ud83c\udd92","COPYRIGHT":"\u00a9","CRANE":"\ud83c\udfd7","CRAYON":"\ud83d\udd8d","CREDITCARD":"\ud83d\udcb3","CROSS":"\u2716","CROSSBOX:":"\u274e","CRY":"\ud83d\ude22","CRYCAT":"\ud83d\ude3f","CRYSTALBALL":"\ud83d\udd2e","CUSTOMS":"\ud83d\udec3","DELICIOUS":"\ud83d\ude0b","DERELICT":"\ud83c\udfda","DESKTOP":"\ud83d\udda5","DIAMONDLB":"\ud83d\udd37","DIAMONDLO":"\ud83d\udd36","DIAMONDSB":"\ud83d\udd39","DIAMONDSO":"\ud83d\udd38","DICE":"\ud83c\udfb2","DISAPPOINTED":"\ud83d\ude1e","CRY2":"\ud83d\ude25","DIVISION":"\u2797","DIZZY":"\ud83d\ude35","DOLLAR":"\ud83d\udcb5","DOLLAR2":"\ud83d\udcb2","DOWNARROW":"\u2b07","DVD":"\ud83d\udcc0","EJECT":"\u23cf","ELEPHANT":"\ud83d\udc18","EMAIL":"\ud83d\udce7","ENVELOPE":"\ud83d\udce8","ENVELOPE2":"\u2709","ENVELOPE_DOWN":"\ud83d\udce9","EURO":"\ud83d\udcb6","EVIL":"\ud83d\ude08","EXPRESSIONLESS":"\ud83d\ude11","EYES":"\ud83d\udc40","FACTORY":"\ud83c\udfed","FAX":"\ud83d\udce0","FEARFUL":"\ud83d\ude28","FILEBOX":"\ud83d\uddc3","FILECABINET":"\ud83d\uddc4","FIRE":"\ud83d\udd25","FIREENGINE":"\ud83d\ude92","FIST":"\ud83d\udc4a","FLOWER":"\ud83c\udf37","FLOWER2":"\ud83c\udf38","FLUSHED":"\ud83d\ude33","FOLDER":"\ud83d\udcc1","FOLDER2":"\ud83d\udcc2","FREE":"\ud83c\udd93","FROG":"\ud83d\udc38","FROWN":"\ud83d\ude41","GEAR":"\u2699","GLOBE":"\ud83c\udf0d","GLOWINGSTAR":"\ud83c\udf1f","GOOD:":"\ud83d\udc4d","GRIMACING":"\ud83d\ude2c","GRIN":"\ud83d\ude00","GRINNINGCAT":"\ud83d\ude38","HALO":"\ud83d\ude07","HAMMER":"\ud83d\udd28","HAMSTER":"\ud83d\udc39","HAND":"\u270b","HANDDOWN":"\ud83d\udc47","HANDLEFT":"\ud83d\udc48","HANDRIGHT":"\ud83d\udc49","HANDUP":"\ud83d\udc46","HATCHING":"\ud83d\udc23","HAZARD":"\u2623","HEADPHONE":"\ud83c\udfa7","HEARNOEVIL":"\ud83d\ude49","HEARTBLUE":"\ud83d\udc99","HEARTEYES":"\ud83d\ude0d","HEARTGREEN":"\ud83d\udc9a","HEARTYELLOW":"\ud83d\udc9b","HELICOPTER":"\ud83d\ude81","HERB":"\ud83c\udf3f","HIGH_BRIGHTNESS":"\ud83d\udd06","HIGHVOLTAGE":"\u26a1","HIT":"\ud83c\udfaf","HONEY":"\ud83c\udf6f","HOT":"\ud83c\udf36","HOURGLASS":"\u23f3","HOUSE":"\ud83c\udfe0","HUGGINGFACE":"\ud83e\udd17","HUNDRED":"\ud83d\udcaf","HUSHED":"\ud83d\ude2f","ID":"\ud83c\udd94","INBOX":"\ud83d\udce5","INDEX":"\ud83d\uddc2","JOY":"\ud83d\ude02","KEY":"\ud83d\udd11","KISS":"\ud83d\ude18","KISS2":"\ud83d\ude17","KISS3":"\ud83d\ude19","KISS4":"\ud83d\ude1a","KISSINGCAT":"\ud83d\ude3d","KNIFE":"\ud83d\udd2a","LABEL":"\ud83c\udff7","LADYBIRD":"\ud83d\udc1e","LANDING":"\ud83d\udeec","LAPTOP":"\ud83d\udcbb","LEFTARROW":"\u2b05","LEMON":"\ud83c\udf4b","LIGHTNINGCLOUD":"\ud83c\udf29","LINK":"\ud83d\udd17","LITTER":"\ud83d\udeae","LOCK":"\ud83d\udd12","LOLLIPOP":"\ud83c\udf6d","LOUDSPEAKER":"\ud83d\udce2","LOW_BRIGHTNESS":"\ud83d\udd05","MAD":"\ud83d\ude1c","MAGNIFYING_GLASS":"\ud83d\udd0d","MASK":"\ud83d\ude37","MEDAL":"\ud83c\udf96","MEMO":"\ud83d\udcdd","MIC":"\ud83c\udfa4","MICROSCOPE":"\ud83d\udd2c","MINUS":"\u2796","MOBILE":"\ud83d\udcf1","MONEY":"\ud83d\udcb0","MONEYMOUTH":"\ud83e\udd11","MONKEY":"\ud83d\udc35","MOUSE":"\ud83d\udc2d","MOUSE2":"\ud83d\udc01","MOUTHLESS":"\ud83d\ude36","MOVIE":"\ud83c\udfa5","MUGS":"\ud83c\udf7b","NERD":"\ud83e\udd13","NEUTRAL":"\ud83d\ude10","NEW":"\ud83c\udd95","NOENTRY":"\ud83d\udeab","NOTEBOOK":"\ud83d\udcd4","NOTEPAD":"\ud83d\uddd2","NUTANDBOLT":"\ud83d\udd29","O":"\u2b55","OFFICE":"\ud83c\udfe2","OK":"\ud83c\udd97","OKHAND":"\ud83d\udc4c","OLDKEY":"\ud83d\udddd","OPENLOCK":"\ud83d\udd13","OPENMOUTH":"\ud83d\ude2e","OUTBOX":"\ud83d\udce4","PACKAGE":"\ud83d\udce6","PAGE":"\ud83d\udcc4","PAINTBRUSH":"\ud83d\udd8c","PALETTE":"\ud83c\udfa8","PANDA":"\ud83d\udc3c","PASSPORT":"\ud83d\udec2","PAWS":"\ud83d\udc3e","PEN":"\ud83d\udd8a","PEN2":"\ud83d\udd8b","PENSIVE":"\ud83d\ude14","PERFORMING":"\ud83c\udfad","PHONE":"\ud83d\udcde","PILL":"\ud83d\udc8a","PING":"\u2757","PLATE":"\ud83c\udf7d","PLUG":"\ud83d\udd0c","PLUS":"\u2795","POLICE":"\ud83d\ude93","POLICELIGHT":"\ud83d\udea8","POSTOFFICE":"\ud83c\udfe4","POUND":"\ud83d\udcb7","POUTING":"\ud83d\ude21","POUTINGCAT":"\ud83d\ude3e","PRESENT":"\ud83c\udf81","PRINTER":"\ud83d\udda8","PROJECTOR":"\ud83d\udcfd","PUSHPIN":"\ud83d\udccc","QUESTION":"\u2753","RABBIT":"\ud83d\udc30","RADIOACTIVE":"\u2622","RADIOBUTTON":"\ud83d\udd18","RAINCLOUD":"\ud83c\udf27","RAT":"\ud83d\udc00","RECYCLE":"\u267b","REGISTERED":"\u00ae","RELIEVED":"\ud83d\ude0c","ROBOT":"\ud83e\udd16","ROCKET":"\ud83d\ude80","ROLLING":"\ud83d\ude44","ROOSTER":"\ud83d\udc13","RULER":"\ud83d\udccf","SATELLITE":"\ud83d\udef0","SAVE":"\ud83d\udcbe","SCHOOL":"\ud83c\udfeb","SCISSORS":"\u2702","SCREAMING":"\ud83d\ude31","SCROLL":"\ud83d\udcdc","SEAT":"\ud83d\udcba","SEEDLING":"\ud83c\udf31","SEENOEVIL":"\ud83d\ude48","SHIELD":"\ud83d\udee1","SHIP":"\ud83d\udea2","SHOCKED":"\ud83d\ude32","SHOWER":"\ud83d\udebf","SLEEPING":"\ud83d\ude34","SLEEPY":"\ud83d\ude2a","SLIDER":"\ud83c\udf9a","SLOT":"\ud83c\udfb0","SMILE":"\ud83d\ude42","SMILING":"\ud83d\ude03","SMILINGCLOSEDEYES":"\ud83d\ude06","SMILINGEYES":"\ud83d\ude04","SMILINGSWEAT":"\ud83d\ude05","SMIRK":"\ud83d\ude0f","SNAIL":"\ud83d\udc0c","SNAKE":"\ud83d\udc0d","SOCCER":"\u26bd","SOS":"\ud83c\udd98","SPEAKER":"\ud83d\udd08","SPEAKEROFF":"\ud83d\udd07","SPEAKNOEVIL":"\ud83d\ude4a","SPIDER":"\ud83d\udd77","SPIDERWEB":"\ud83d\udd78","STAR":"\u2b50","STOP":"\u26d4","STOPWATCH":"\u23f1","SULK":"\ud83d\ude26","SUNFLOWER":"\ud83c\udf3b","SUNGLASSES":"\ud83d\udd76","SYRINGE":"\ud83d\udc89","TAKEOFF":"\ud83d\udeeb","TAXI":"\ud83d\ude95","TELESCOPE":"\ud83d\udd2d","TEMPORATURE":"\ud83e\udd12","TENNIS":"\ud83c\udfbe","THERMOMETER":"\ud83c\udf21","THINKING":"\ud83e\udd14","THUNDERCLOUD":"\u26c8","TICKBOX":"\u2705","TICKET":"\ud83c\udf9f","TIRED":"\ud83d\ude2b","TOILET":"\ud83d\udebd","TOMATO":"\ud83c\udf45","TONGUE":"\ud83d\ude1b","TOOLS":"\ud83d\udee0","TORCH":"\ud83d\udd26","TORNADO":"\ud83c\udf2a","TOUNG2":"\ud83d\ude1d","TRADEMARK":"\u2122","TRAFFICLIGHT":"\ud83d\udea6","TRASH":"\ud83d\uddd1","TREE":"\ud83c\udf32","TRIANGLE_LEFT":"\u25c0","TRIANGLE_RIGHT":"\u25b6","TRIANGLEDOWN":"\ud83d\udd3b","TRIANGLEUP":"\ud83d\udd3a","TRIANGULARFLAG":"\ud83d\udea9","TROPHY":"\ud83c\udfc6","TRUCK":"\ud83d\ude9a","TRUMPET":"\ud83c\udfba","TURKEY":"\ud83e\udd83","TURTLE":"\ud83d\udc22","UMBRELLA":"\u26f1","UNAMUSED":"\ud83d\ude12","UPARROW":"\u2b06","UPSIDEDOWN":"\ud83d\ude43","WARNING":"\u26a0","WATCH":"\u231a","WAVING":"\ud83d\udc4b","WEARY":"\ud83d\ude29","WEARYCAT":"\ud83d\ude40","WHITEFLAG":"\ud83c\udff3","WINEGLASS":"\ud83c\udf77","WINK":"\ud83d\ude09","WORRIED":"\ud83d\ude1f","WRENCH":"\ud83d\udd27","X":"\u274c","YEN":"\ud83d\udcb4","ZIPPERFACE":"\ud83e\udd10","UNDEFINED":"","":""};statuses={F:'FATAL',B:'BUG',C:'CRITICAL',E:'ERROR',W:'WARNING',I:'INFO',IM:'IMPORTANT',D:'DEBUG',L:'LOG',CO:'CONSTANT',FU:'FUNCTION',R:'RETURN',V:'VARIABLE',S:'STACK',RE:'RESULT',ST:'STOPPER',TI:'TIMER',T:'TRACE'};LOG_LEVEL={NONE:7,OFF:7,FATAL:6,ERROR:5,WARN:4,INFO:3,UNDEFINED:2,'':2,DEFAULT:2,DEBUG:2,TRACE:1,ON:0,ALL:0,};LOG_STATUS={OFF:LOG_LEVEL.OFF,NONE:LOG_LEVEL.OFF,NO:LOG_LEVEL.OFF,NOPE:LOG_LEVEL.OFF,FALSE:LOG_LEVEL.OFF,FATAL:LOG_LEVEL.FATAL,BUG:LOG_LEVEL.ERROR,CRITICAL:LOG_LEVEL.ERROR,ERROR:LOG_LEVEL.ERROR,WARNING:LOG_LEVEL.WARN,INFO:LOG_LEVEL.INFO,IMPORTANT:LOG_LEVEL.INFO,DEBUG:LOG_LEVEL.DEBUG,LOG:LOG_LEVEL.DEBUG,STACK:LOG_LEVEL.DEBUG,CONSTANT:LOG_LEVEL.DEBUG,FUNCTION:LOG_LEVEL.DEBUG,VARIABLE:LOG_LEVEL.DEBUG,RETURN:LOG_LEVEL.DEBUG,RESULT:LOG_LEVEL.TRACE,STOPPER:LOG_LEVEL.TRACE,TIMER:LOG_LEVEL.TRACE,TRACE:LOG_LEVEL.TRACE,ALL:LOG_LEVEL.ALL,YES:LOG_LEVEL.ALL,YEP:LOG_LEVEL.ALL,TRUE:LOG_LEVEL.ALL};var logFile,logFolder;var LOG=function(message,status,icon){if(LOG.level!==LOG_LEVEL.OFF&&(LOG.write||LOG.store)&&LOG.arguments.length)return LOG.addMessage(message,status,icon);};LOG.logDecodeLevel=function(level){if(level==~~level)return Math.abs(level);var lev;level+='';level=level.toUpperCase();if(level in statuses){level=statuses[level];}lev=LOG_LEVEL[level];if(lev!==undefined)return lev;lev=LOG_STATUS[level];if(lev!==undefined)return lev;return LOG_LEVEL.DEFAULT;};LOG.write=write;LOG.store=store;LOG.level=LOG.logDecodeLevel(level);LOG.status=defaultStatus;LOG.addMessage=function(message,status,icon){var date=new Date(),count,bool,logStatus;if(status&&status.constructor.name==='String'){status=status.toUpperCase();status=statuses[status]||status;}else status=LOG.status;logStatus=LOG_STATUS[status]||LOG_STATUS.ALL;if(logStatus999)?'['+LOG.count+'] ':(' ['+LOG.count+'] ').slice(-7);message=count+status+icon+(message instanceof Object?message.toSource():message)+date;if(LOG.store){stack.push(message);}if(LOG.write){bool=file&&file.writable&&logFile.writeln(message);if(!bool){file.writable=true;LOG.setFile(logFile);logFile.writeln(message);}}LOG.count++;return true;};var logNewFile=function(file,isCookie,overwrite){file.encoding='UTF-8';file.lineFeed=($.os[0]=='M')?'Macintosh':' Windows';if(isCookie)return file.open(overwrite?'w':'e')&&file;file.writable=LOG.write;logFile=file;logFolder=file.parent;if(continuing){LOG.count=LOG.setCount(file);}return(!LOG.write&&file||(file.open('a')&&file));};LOG.setFile=function(file,isCookie,overwrite){var bool,folder,fileName,suffix,newFileName,f,d,safeFileName;d=new Date();f=$.stack.split("\n")[0].replace(/^\[\(?/,'').replace(/\)?\]$/,'');if(f==~~f){f=$.fileName.replace(/[^\/]+\//g,'');}safeFileName=File.encode((isCookie?'/COOKIE_':'/LOG_')+f.replace(/^\//,'')+'_'+(1900+d.getYear())+(''+d).replace(/...(...)(..).+/,'_$1_$2')+(isCookie?'.txt':'.log'));if(file&&file.constructor.name=='String'){file=(file.match('/'))?new File(file):new File((logFolder||Folder.temp)+'/'+file);}if(file instanceof File){folder=file.parent;bool=folder.exists||folder.create();if(!bool)folder=Folder.temp;fileName=File.decode(file.name);suffix=fileName.match(/\.[^.]+$/);suffix=suffix?suffix[0]:'';fileName='/'+fileName;newFileName=fileName.replace(/\.[^.]+$/,'')+'_'+(+(new Date())+suffix);f=logNewFile(file,isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+newFileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+safeFileName),isCookie,overwrite);if(f)return f;if(folder!=Folder.temp){f=logNewFile(new File(Folder.temp+fileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(Folder.temp+safeFileName),isCookie,overwrite);return f||new File(Folder.temp+safeFileName);}}return LOG.setFile(((logFile&&!isCookie)?new File(logFile):new File(Folder.temp+safeFileName)),isCookie,overwrite );};LOG.setCount=function(file){if(~~file===file){LOG.count=file;return LOG.count;}if(file===undefined){file=logFile;}if(file&&file.constructor===String){file=new File(file);}var logNumbers,contents;if(!file.length||!file.exists){LOG.count=1;return 1;}file.open('r');file.encoding='utf-8';file.seek(10000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}if(file.length<10001){file.close();LOG.count=1;return 1;}file.seek(10000000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}file.close();LOG.count=1;return 1;};LOG.setLevel=function(level){LOG.level=LOG.logDecodeLevel(level);return LOG.level;};LOG.setStatus=function(status){status=(''+status).toUpperCase();LOG.status=statuses[status]||status;return LOG.status;};LOG.cookie=function(file,level,overwrite,setLevel){var log,cookie;if(!file){file={file:file};}if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}log=file;if(log.level===undefined){log.level=(level!==undefined)?level:'NONE';}if(log.overwrite===undefined){log.overwrite=(overwrite!==undefined)?overwrite:false;}if(log.setLevel===undefined){log.setLevel=(setLevel!==undefined)?setLevel:true;}setLevel=log.setLevel;overwrite=log.overwrite;level=log.level;file=log.file;file=LOG.setFile(file,true,overwrite);if(overwrite){file.write(level);}else{cookie=file.read();if(cookie.length){level=cookie;}else{file.write(level);}}file.close();if(setLevel){LOG.setLevel(level);}return{path:file,level:level};};LOG.args=function(args,funct,line){if(LOG.level>LOG_STATUS.FUNCTION)return;if(!(args&&(''+args.constructor).replace(/\s+/g,'')==='functionObject(){[nativecode]}'))return;if(!LOG.args.STRIP_COMMENTS){LOG.args.STRIP_COMMENTS=/((\/.*$)|(\/\*[\s\S]*?\*\/))/mg;}if(!LOG.args.ARGUMENT_NAMES){LOG.args.ARGUMENT_NAMES=/([^\s,]+)/g;}if(!LOG.args.OUTER_BRACKETS){LOG.args.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.args.NEW_SOMETHING){LOG.args.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}var functionString,argumentNames,stackInfo,report,functionName,arg,argsL,n,argName,argValue,argsTotal;if(funct===~~funct){line=funct;}if(!(funct instanceof Function)){funct=args.callee;}if(!(funct instanceof Function))return;functionName=funct.name;functionString=(''+funct).replace(LOG.args.STRIP_COMMENTS,'');argumentNames=functionString.slice(functionString.indexOf('(')+1,functionString.indexOf(')')).match(LOG.args.ARGUMENT_NAMES);argumentNames=argumentNames||[];report=[];report.push('--------------');report.push('Function Data:');report.push('--------------');report.push('Function Name:'+functionName);argsL=args.length;stackInfo=$.stack.split(/[\n\r]/);stackInfo.pop();stackInfo=stackInfo.join('\n ');report.push('Call stack:'+stackInfo);if(line){report.push('Function Line around:'+line);}report.push('Arguments Provided:'+argsL);report.push('Named Arguments:'+argumentNames.length);if(argumentNames.length){report.push('Arguments Names:'+argumentNames.join(','));}if(argsL){report.push('----------------');report.push('Argument Values:');report.push('----------------');}argsTotal=Math.max(argsL,argumentNames.length);for(n=0;n=argsL){argValue='NO VALUE PROVIDED';}else if(arg===undefined){argValue='undefined';}else if(arg===null){argValue='null';}else{argValue=arg.toSource().replace(LOG.args.OUTER_BRACKETS,'$1').replace(LOG.args.NEW_SOMETHING,'$1');}report.push((argName?argName:'arguments['+n+']')+':'+argValue);}report.push('');report=report.join('\n ');LOG(report,'f');return report;};LOG.stack=function(reverse){var st=$.stack.split('\n');st.pop();st.pop();if(reverse){st.reverse();}return LOG(st.join('\n '),'s');};LOG.values=function(values){var n,value,map=[];if(!(values instanceof Object||values instanceof Array)){return;}if(!LOG.values.OUTER_BRACKETS){LOG.values.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.values.NEW_SOMETHING){LOG.values.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}for(n in values){try{value=values[n];if(value===undefined){value='undefined';}else if(value===null){value='null';}else{value=value.toSource().replace(LOG.values.OUTER_BRACKETS,'$1').replace(LOG.values.NEW_SOMETHING,'$1');}}catch(e){value='\uD83D\uDEAB '+e;}map.push(n+':'+value);}if(map.length){map=map.join('\n ')+'\n ';return LOG(map,'v');}};LOG.reset=function(all){stack.length=0;LOG.count=1;if(all!==false){if(logFile instanceof File){logFile.close();}logFile=LOG.store=LOG.writeToFile=undefined;LOG.write=true;logFolder=Folder.temp;logTime=new Date();logPoint='After Log Reset';}};LOG.stopper=function(message){var newLogTime,t,m,newLogPoint;newLogTime=new Date();newLogPoint=(LOG.count!==undefined)?'LOG#'+LOG.count:'BEFORE LOG#1';LOG.time=t=newLogTime-logTime;if(message===false){return;}message=message||'Stopper start point';t=LOG.prettyTime(t);m=message+'\n '+'From '+logPoint+' to '+newLogPoint+' took '+t+' Starting '+logTime+' '+logTime.getMilliseconds()+'ms'+' Ending '+newLogTime+' '+newLogTime.getMilliseconds()+'ms';LOG(m,'st');logPoint=newLogPoint;logTime=newLogTime;return m;};LOG.start=function(message){var t=new Date();times.push([t,(message!==undefined)?message+'':'']);};LOG.stop=function(message){if(!times.length)return;message=(message)?message+' ':'';var nt,startLog,ot,om,td,m;nt=new Date();startLog=times.pop();ot=startLog[0];om=startLog[1];td=nt-ot;if(om.length){om+=' ';}m=om+'STARTED ['+ot+' '+ot.getMilliseconds()+'ms]\n '+message+'FINISHED ['+nt+' '+nt.getMilliseconds()+'ms]\n TOTAL TIME ['+LOG.prettyTime(td)+']';LOG(m,'ti');return m;};LOG.prettyTime=function(t){var h,m,s,ms;h=Math.floor(t / 3600000);m=Math.floor((t % 3600000)/ 60000);s=Math.floor((t % 60000)/ 1000);ms=t % 1000;t=(!t)?'<1ms':((h)?h+' hours ':'')+((m)?m+' minutes ':'')+((s)?s+' seconds ':'')+((ms&&(h||m||s))?'&':'')+((ms)?ms+'ms':'');return t;};LOG.get=function(){if(!stack.length)return 'THE LOG IS NOT SET TO STORE';var a=fetchLogLines(arguments);return a?'\n'+a.join('\n'):'NO LOGS AVAILABLE';};var fetchLogLines=function(){var args=arguments[0];if(!args.length)return stack;var c,n,l,a=[],ln,start,end,j,sl;l=args.length;sl=stack.length-1;n=0;for(c=0;cln)?sl+ln+1:ln-1;if(ln>=0&&ln<=sl)a[n++]=stack[ln];}else if(ln instanceof Array&&ln.length===2){start=ln[0];end=ln[1];if(!(~~start===start&&~~end===end))continue;start=(0>start)?sl+start+1:start-1;end=(0>end)?sl+end+1:end-1;start=Math.max(Math.min(sl,start),0);end=Math.min(Math.max(end,0),sl);if(start<=end)for(j=start;j<=end;j++)a[n++]=stack[j];else for(j=start;j>=end;j--)a[n++]=stack[j];}}return(n)?a:false;};LOG.file=function(){return logFile;};LOG.openFolder=function(){if(logFolder)return logFolder.execute();};LOG.show=LOG.execute=function(){if(logFile)return logFile.execute();};LOG.close=function(){if(logFile)return logFile.close();};LOG.setFile(file);if(!$.summary.difference){$.summary.difference=function(){return $.summary().replace(/ *([0-9]+)([^ ]+)(\n?)/g,$.summary.updateSnapshot );};}if(!$.summary.updateSnapshot){$.summary.updateSnapshot=function(full,count,name,lf){var snapshot=$.summary.snapshot;count=Number(count);var prev=snapshot[name]?snapshot[name]:0;snapshot[name]=count;var diff=count-prev;if(diff===0)return "";return " ".substring(String(diff).length)+diff+" "+name+lf;};}if(!$.summary.snapshot){$.summary.snapshot=[];$.summary.difference();}$.gc();$.gc();$.summary.difference();LOG.sumDiff=function(message){$.gc();$.gc();var diff=$.summary.difference();if(diff.length<8){diff=' - NONE -';}if(message===undefined){message='';}message+=diff;return LOG('$.summary.difference():'+message,'v');};return LOG;}; + +var log = new LogFactory('myLog.log'); // =>; creates the new log factory - put full path where + +function getEnv(variable){ + return $.getenv(variable); +} + +function fileOpen(path){ + return app.open(new File(path)); +} + +function getLayerTypeWithName(layerName) { + var type = 'NA'; + var nameParts = layerName.split('_'); + var namePrefix = nameParts[0]; + namePrefix = namePrefix.toLowerCase(); + switch (namePrefix) { + case 'guide': + case 'tl': + case 'tr': + case 'bl': + case 'br': + type = 'GUIDE'; + break; + case 'fg': + type = 'FG'; + break; + case 'bg': + type = 'BG'; + break; + case 'obj': + default: + type = 'OBJ'; + break; + } + + return type; +} + +function getLayers() { + /** + * Get json representation of list of layers. + * Much faster this way than in DOM traversal (2s vs 45s on same file) + * + * Format of single layer info: + * id : number + * name: string + * group: boolean - true if layer is a group + * parents:array - list of ids of parent groups, useful for selection + * all children layers from parent layerSet (eg. group) + * type: string - type of layer guessed from its name + * visible:boolean - true if visible + **/ + if (documents.length == 0){ + return '[]'; + } + var ref1 = new ActionReference(); + ref1.putEnumerated(charIDToTypeID('Dcmn'), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt')); + var count = executeActionGet(ref1).getInteger(charIDToTypeID('NmbL')); + + // get all layer names + var layers = []; + var layer = {}; + + var parents = []; + for (var i = count; i >= 1; i--) { + var layer = {}; + var ref2 = new ActionReference(); + ref2.putIndex(charIDToTypeID('Lyr '), i); + + var desc = executeActionGet(ref2); // Access layer index #i + var layerSection = typeIDToStringID(desc.getEnumerationValue( + stringIDToTypeID('layerSection'))); + + layer.id = desc.getInteger(stringIDToTypeID("layerID")); + layer.name = desc.getString(stringIDToTypeID("name")); + layer.color_code = typeIDToStringID(desc.getEnumerationValue(stringIDToTypeID('color'))); + layer.group = false; + layer.parents = parents.slice(); + layer.type = getLayerTypeWithName(layer.name); + layer.visible = desc.getBoolean(stringIDToTypeID("visible")); + //log(" name: " + layer.name + " groupId " + layer.groupId + + //" group " + layer.group); + if (layerSection == 'layerSectionStart') { // Group start and end + parents.push(layer.id); + layer.group = true; + } + if (layerSection == 'layerSectionEnd') { + parents.pop(); + continue; + } + layers.push(JSON.stringify(layer)); + } + try{ + var bck = activeDocument.backgroundLayer; + layer.id = bck.id; + layer.name = bck.name; + layer.group = false; + layer.parents = []; + layer.type = 'background'; + layer.visible = bck.visible; + layers.push(JSON.stringify(layer)); + }catch(e){ + // do nothing, no background layer + }; + //log("layers " + layers); + return '[' + layers + ']'; +} + +function setVisible(layer_id, visibility){ + /** + * Sets particular 'layer_id' to 'visibility' if true > show + **/ + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + executeAction(visibility?stringIDToTypeID("show"):stringIDToTypeID("hide"), + desc, DialogModes.NO); + +} + +function getHeadline(){ + /** + * Returns headline of current document with metadata + * + **/ + if (documents.length == 0){ + return ''; + } + var headline = app.activeDocument.info.headline; + + return headline; +} + +function isSaved(){ + return app.activeDocument.saved; +} + +function save(){ + /** Saves active document **/ + return app.activeDocument.save(); +} + +function saveAs(output_path, ext, as_copy){ + /** Exports scene to various formats + * + * Currently implemented: 'jpg', 'png', 'psd' + * + * output_path - escaped file path on local system + * ext - extension for export + * as_copy - create copy, do not overwrite + * + * */ + var saveName = output_path; + var saveOptions; + if (ext == 'jpg'){ + saveOptions = new JPEGSaveOptions(); + saveOptions.quality = 12; + saveOptions.embedColorProfile = true; + saveOptions.formatOptions = FormatOptions.PROGRESSIVE; + if(saveOptions.formatOptions == FormatOptions.PROGRESSIVE){ + saveOptions.scans = 5}; + saveOptions.matte = MatteType.NONE; + } + if (ext == 'png'){ + saveOptions = new PNGSaveOptions(); + saveOptions.interlaced = true; + saveOptions.transparency = true; + } + if (ext == 'psd'){ + saveOptions = null; + return app.activeDocument.saveAs(new File(saveName)); + } + if (ext == 'psb'){ + return savePSB(output_path); + } + + return app.activeDocument.saveAs(new File(saveName), saveOptions, as_copy); + +} + +function getActiveDocumentName(){ + /** + * Returns file name of active document + * */ + if (documents.length == 0){ + return null; + } + return app.activeDocument.name; +} + +function getActiveDocumentFullName(){ + /** + * Returns file name of active document with file path. + * activeDocument.fullName returns path in URI (eg /c/.. insted of c:/) + * */ + if (documents.length == 0){ + return null; + } + var f = new File(app.activeDocument.fullName); + var path = f.fsName; + f.close(); + return path; +} + +function imprint(payload){ + /** + * Sets headline content of current document with metadata. Stores + * information about assets created through Avalon. + * Content accessible in PS through File > File Info + * + **/ + app.activeDocument.info.headline = payload; +} + +function getSelectedLayers(doc) { + /** + * Returns json representation of currently selected layers. + * Works in three steps - 1) creates new group with selected layers + * 2) traverses this group + * 3) deletes newly created group, not neede + * Bit weird, but Adobe.. + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var selLayers = []; + _grp = groupSelectedLayers(doc); + + var group = doc.activeLayer; + var layers = group.layers; + + // // group is fake at this point + // var itself_name = ''; + // if (layers){ + // itself_name = layers[0].name; + // } + + + for (var i = 0; i < layers.length; i++) { + var layer = {}; + layer.id = layers[i].id; + layer.name = layers[i].name; + long_names =_get_parents_names(group.parent, layers[i].name); + var t = layers[i].kind; + if ((typeof t !== 'undefined') && + (layers[i].kind.toString() == 'LayerKind.NORMAL')){ + layer.group = false; + }else{ + layer.group = true; + } + layer.long_name = long_names; + + selLayers.push(layer); + } + + _undo(); + + return JSON.stringify(selLayers); +}; + +function selectLayers(selectedLayers){ + /** + * Selects layers from list of ids + **/ + selectedLayers = JSON.parse(selectedLayers); + var layers = new Array(); + var id54 = charIDToTypeID( "slct" ); + var desc12 = new ActionDescriptor(); + var id55 = charIDToTypeID( "null" ); + var ref9 = new ActionReference(); + + var existing_layers = JSON.parse(getLayers()); + var existing_ids = []; + for (var y = 0; y < existing_layers.length; y++){ + existing_ids.push(existing_layers[y]["id"]); + } + for (var i = 0; i < selectedLayers.length; i++) { + // a check to see if the id stil exists + var id = selectedLayers[i]; + if(existing_ids.toString().indexOf(id)>=0){ + layers[i] = charIDToTypeID( "Lyr " ); + ref9.putIdentifier(layers[i], id); + } + } + desc12.putReference( id55, ref9 ); + var id58 = charIDToTypeID( "MkVs" ); + desc12.putBoolean( id58, false ); + executeAction( id54, desc12, DialogModes.NO ); +} + +function groupSelectedLayers(doc, name) { + /** + * Groups selected layers into new group. + * Returns json representation of Layer for server to consume + * + * Args: + * doc(activeDocument) + * name (str): new name of created group + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putClass( stringIDToTypeID('layerSection') ); + desc.putReference( charIDToTypeID('null'), ref ); + var lref = new ActionReference(); + lref.putEnumerated( charIDToTypeID('Lyr '), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt') ); + desc.putReference( charIDToTypeID('From'), lref); + executeAction( charIDToTypeID('Mk '), desc, DialogModes.NO ); + + var group = doc.activeLayer; + if (name){ + // Add special character to highlight group that will be published + group.name = name; + } + var layer = {}; + layer.id = group.id; + layer.name = name; // keep name clean + layer.group = true; + + layer.long_name = _get_parents_names(group, name); + + return JSON.stringify(layer); +}; + +function importSmartObject(path, name, link){ + /** + * Creates new layer with an image from 'path' + * + * path: absolute path to loaded file + * name: sets name of newly created laye + * + **/ + var desc1 = new ActionDescriptor(); + desc1.putPath( app.charIDToTypeID("null"), new File(path) ); + link = link || false; + if (link) { + desc1.putBoolean( app.charIDToTypeID('Lnkd'), true ); + } + + desc1.putEnumerated(app.charIDToTypeID("FTcs"), app.charIDToTypeID("QCSt"), + app.charIDToTypeID("Qcsa")); + var desc2 = new ActionDescriptor(); + desc2.putUnitDouble(app.charIDToTypeID("Hrzn"), + app.charIDToTypeID("#Pxl"), 0.0); + desc2.putUnitDouble(app.charIDToTypeID("Vrtc"), + app.charIDToTypeID("#Pxl"), 0.0); + + desc1.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), desc2); + executeAction(charIDToTypeID("Plc " ), desc1, DialogModes.NO); + + var docRef = app.activeDocument + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } + var layer = {} + layer.id = currentActivelayer.id; + layer.name = currentActivelayer.name; + return JSON.stringify(layer); +} + +function replaceSmartObjects(layer_id, path, name){ + /** + * Updates content of 'layer' with an image from 'path' + * + **/ + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + desc.putPath(charIDToTypeID('null'), new File(path) ); + desc.putInteger(charIDToTypeID("PgNm"), 1); + + executeAction(stringIDToTypeID('placedLayerReplaceContents'), + desc, DialogModes.NO ); + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } +} + +function createGroup(name){ + /** + * Creates new group with a 'name' + * Because of asynchronous nature, only group.id is available + **/ + group = app.activeDocument.layerSets.add(); + // Add special character to highlight group that will be published + group.name = name; + + return group.id; // only id available at this time :| +} + +function deleteLayer(layer_id){ + /*** + * Deletes layer by its layer_id + * + * layer_id (int) + **/ + var d = new ActionDescriptor(); + var r = new ActionReference(); + + r.putIdentifier(stringIDToTypeID("layer"), layer_id); + d.putReference(stringIDToTypeID("null"), r); + executeAction(stringIDToTypeID("delete"), d, DialogModes.NO); +} + +function _undo() { + executeAction(charIDToTypeID("undo", undefined, DialogModes.NO)); +}; + +function savePSB(output_path){ + /*** + * Saves file as .psb to 'output_path' + * + * output_path (str) + **/ + var desc1 = new ActionDescriptor(); + var desc2 = new ActionDescriptor(); + desc2.putBoolean( stringIDToTypeID('maximizeCompatibility'), true ); + desc1.putObject( charIDToTypeID('As '), charIDToTypeID('Pht8'), desc2 ); + desc1.putPath( charIDToTypeID('In '), new File(output_path) ); + desc1.putBoolean( charIDToTypeID('LwCs'), true ); + executeAction( charIDToTypeID('save'), desc1, DialogModes.NO ); +} + +function close(){ + executeAction(stringIDToTypeID("quit"), undefined, DialogModes.NO ); +} + +function renameLayer(layer_id, new_name){ + /*** + * Renames 'layer_id' to 'new_name' + * + * Via Action (fast) + * + * Args: + * layer_id(int) + * new_name(str) + * + * output_path (str) + **/ + doc = app.activeDocument; + selectLayers('['+layer_id+']'); + + doc.activeLayer.name = new_name; +} + +function _get_parents_names(layer, itself_name){ + var long_names = [itself_name]; + while (layer.parent){ + if (layer.typename != "LayerSet"){ + break; + } + long_names.push(layer.name); + layer = layer.parent; + } + return long_names; +} + +// triggers when panel is opened, good for debugging +//log(getActiveDocumentName()); +// log.show(); +// var a = app.activeDocument.activeLayer; +// log(a); +//getSelectedLayers(); +// importSmartObject("c:/projects/test.jpg", "a aaNewLayer", true); +// log("dpc"); +// replaceSmartObjects(153, "▼Jungle_imageTest_001", "c:/projects/test_project_test_asset_TestTask_v001.png"); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/host/json.js b/openpype/hosts/photoshop/api/extension/host/json.js new file mode 100644 index 0000000000..397349bbfd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/json.js @@ -0,0 +1,530 @@ +// json2.js +// 2017-06-12 +// Public Domain. +// NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. + +// USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO +// NOT CONTROL. + +// This file creates a global JSON object containing two methods: stringify +// and parse. This file provides the ES5 JSON capability to ES3 systems. +// If a project might run on IE8 or earlier, then this file should be included. +// This file does nothing on ES5 systems. + +// JSON.stringify(value, replacer, space) +// value any JavaScript value, usually an object or array. +// replacer an optional parameter that determines how object +// values are stringified for objects. It can be a +// function or an array of strings. +// space an optional parameter that specifies the indentation +// of nested structures. If it is omitted, the text will +// be packed without extra whitespace. If it is a number, +// it will specify the number of spaces to indent at each +// level. If it is a string (such as "\t" or " "), +// it contains the characters used to indent at each level. +// This method produces a JSON text from a JavaScript value. +// When an object value is found, if the object contains a toJSON +// method, its toJSON method will be called and the result will be +// stringified. A toJSON method does not serialize: it returns the +// value represented by the name/value pair that should be serialized, +// or undefined if nothing should be serialized. The toJSON method +// will be passed the key associated with the value, and this will be +// bound to the value. + +// For example, this would serialize Dates as ISO strings. + +// Date.prototype.toJSON = function (key) { +// function f(n) { +// // Format integers to have at least two digits. +// return (n < 10) +// ? "0" + n +// : n; +// } +// return this.getUTCFullYear() + "-" + +// f(this.getUTCMonth() + 1) + "-" + +// f(this.getUTCDate()) + "T" + +// f(this.getUTCHours()) + ":" + +// f(this.getUTCMinutes()) + ":" + +// f(this.getUTCSeconds()) + "Z"; +// }; + +// You can provide an optional replacer method. It will be passed the +// key and value of each member, with this bound to the containing +// object. The value that is returned from your method will be +// serialized. If your method returns undefined, then the member will +// be excluded from the serialization. + +// If the replacer parameter is an array of strings, then it will be +// used to select the members to be serialized. It filters the results +// such that only members with keys listed in the replacer array are +// stringified. + +// Values that do not have JSON representations, such as undefined or +// functions, will not be serialized. Such values in objects will be +// dropped; in arrays they will be replaced with null. You can use +// a replacer function to replace those with JSON values. + +// JSON.stringify(undefined) returns undefined. + +// The optional space parameter produces a stringification of the +// value that is filled with line breaks and indentation to make it +// easier to read. + +// If the space parameter is a non-empty string, then that string will +// be used for indentation. If the space parameter is a number, then +// the indentation will be that many spaces. + +// Example: + +// text = JSON.stringify(["e", {pluribus: "unum"}]); +// // text is '["e",{"pluribus":"unum"}]' + +// text = JSON.stringify(["e", {pluribus: "unum"}], null, "\t"); +// // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' + +// text = JSON.stringify([new Date()], function (key, value) { +// return this[key] instanceof Date +// ? "Date(" + this[key] + ")" +// : value; +// }); +// // text is '["Date(---current time---)"]' + +// JSON.parse(text, reviver) +// This method parses a JSON text to produce an object or array. +// It can throw a SyntaxError exception. + +// The optional reviver parameter is a function that can filter and +// transform the results. It receives each of the keys and values, +// and its return value is used instead of the original value. +// If it returns what it received, then the structure is not modified. +// If it returns undefined then the member is deleted. + +// Example: + +// // Parse the text. Values that look like ISO date strings will +// // be converted to Date objects. + +// myData = JSON.parse(text, function (key, value) { +// var a; +// if (typeof value === "string") { +// a = +// /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); +// if (a) { +// return new Date(Date.UTC( +// +a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6] +// )); +// } +// return value; +// } +// }); + +// myData = JSON.parse( +// "[\"Date(09/09/2001)\"]", +// function (key, value) { +// var d; +// if ( +// typeof value === "string" +// && value.slice(0, 5) === "Date(" +// && value.slice(-1) === ")" +// ) { +// d = new Date(value.slice(5, -1)); +// if (d) { +// return d; +// } +// } +// return value; +// } +// ); + +// This is a reference implementation. You are free to copy, modify, or +// redistribute. + +/*jslint + eval, for, this +*/ + +/*property + JSON, apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, + getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, + lastIndex, length, parse, prototype, push, replace, slice, stringify, + test, toJSON, toString, valueOf +*/ + + +// Create a JSON object only if one does not already exist. We create the +// methods in a closure to avoid creating global variables. + +if (typeof JSON !== "object") { + JSON = {}; +} + +(function () { + "use strict"; + + var rx_one = /^[\],:{}\s]*$/; + var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; + var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; + var rx_four = /(?:^|:|,)(?:\s*\[)+/g; + var rx_escapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + var rx_dangerous = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + + function f(n) { + // Format integers to have at least two digits. + return (n < 10) + ? "0" + n + : n; + } + + function this_value() { + return this.valueOf(); + } + + if (typeof Date.prototype.toJSON !== "function") { + + Date.prototype.toJSON = function () { + + return isFinite(this.valueOf()) + ? ( + this.getUTCFullYear() + + "-" + + f(this.getUTCMonth() + 1) + + "-" + + f(this.getUTCDate()) + + "T" + + f(this.getUTCHours()) + + ":" + + f(this.getUTCMinutes()) + + ":" + + f(this.getUTCSeconds()) + + "Z" + ) + : null; + }; + + Boolean.prototype.toJSON = this_value; + Number.prototype.toJSON = this_value; + String.prototype.toJSON = this_value; + } + + var gap; + var indent; + var meta; + var rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + rx_escapable.lastIndex = 0; + return rx_escapable.test(string) + ? "\"" + string.replace(rx_escapable, function (a) { + var c = meta[a]; + return typeof c === "string" + ? c + : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4); + }) + "\"" + : "\"" + string + "\""; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i; // The loop counter. + var k; // The member key. + var v; // The member value. + var length; + var mind = gap; + var partial; + var value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if ( + value + && typeof value === "object" + && typeof value.toJSON === "function" + ) { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === "function") { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case "string": + return quote(value); + + case "number": + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return (isFinite(value)) + ? String(value) + : "null"; + + case "boolean": + case "null": + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce "null". The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is "object", we might be dealing with an object or an array or +// null. + + case "object": + +// Due to a specification blunder in ECMAScript, typeof null is "object", +// so watch out for that case. + + if (!value) { + return "null"; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === "[object Array]") { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || "null"; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 + ? "[]" + : gap + ? ( + "[\n" + + gap + + partial.join(",\n" + gap) + + "\n" + + mind + + "]" + ) + : "[" + partial.join(",") + "]"; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === "object") { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === "string") { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 + ? "{}" + : gap + ? "{\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "}" + : "{" + partial.join(",") + "}"; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== "function") { + meta = { // table of character substitutions + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + "\"": "\\\"", + "\\": "\\\\" + }; + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ""; + indent = ""; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === "number") { + for (i = 0; i < space; i += 1) { + indent += " "; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === "string") { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== "function" && ( + typeof replacer !== "object" + || typeof replacer.length !== "number" + )) { + throw new Error("JSON.stringify"); + } + +// Make a fake root object containing our value under the key of "". +// Return the result of stringifying the value. + + return str("", {"": value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== "function") { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k; + var v; + var value = holder[key]; + if (value && typeof value === "object") { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + rx_dangerous.lastIndex = 0; + if (rx_dangerous.test(text)) { + text = text.replace(rx_dangerous, function (a) { + return ( + "\\u" + + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) + ); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with "()" and "new" +// because they can cause invocation, and "=" because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with "@" (a non-JSON character). Second, we +// replace all simple value tokens with "]" characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or "]" or +// "," or ":" or "{" or "}". If that is so, then the text is safe for eval. + + if ( + rx_one.test( + text + .replace(rx_two, "@") + .replace(rx_three, "]") + .replace(rx_four, "") + ) + ) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The "{" operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval("(" + text + ")"); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return (typeof reviver === "function") + ? walk({"": j}, "") + : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError("JSON.parse"); + }; + } +}()); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png b/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png new file mode 100644 index 0000000000000000000000000000000000000000..33fe2a606bd1ac9d285eb0d6a90b9b14150ca3c4 GIT binary patch literal 1362 zcmV-Y1+DstP)5JpvKiH|A7SK4P~kT{6`M*9LrdQ!Ns9=$qj3(E_W@4gmP#=ht)#^0_psT^(5Px6qr0)mBB%5&-P}{Y*9ph@Pcn`!ete zwiE<#115v#ScdV2GBk!NTFTzWbF>Xip`p8%&KqcqI~Jb6tC``Vaf&07o~axnzSGF( z(ok|5&-4zgtV5rc$qke?7a8cU$D55m^%IcuOgXaxfTb~yegblyEaWJw%`Qe=-M%S@ zhOXSbt2KkcJv{&)s&PL6vC{g1Y-aKYBs(yc@x{whhk_0fK#=N=)Uup zs)>qe=dc=h3&3Gwr10?^8zc#g%1L4Xs{p!rj(uw=)9Szs&#`@sH{=+ zG+fz{pjE0VR%8l+hOX;W8`PbV32glOJ!~I2VXJkTz5Ufkuk(!F8z4>Ok_kkI+Kb}3)n06_ssJy4_*!y{BAe4)9jbBbSR!>UnLxyMT9bL9_?YdfL@K^^G6aZ)C$Qje z(NzKf2bZq2#ed1=gx1ZJQM{TNMk>CBw!wSvUjy@gS4qs1_a85GREVYsFz!+tU$`&M%7iR@HuBiw5bSa5S}|?)>G0PCUMb-Q{Pf zZt0{hEhroOCi1l=h%&q$mkBdG$MzLns~iea1>hEds{qcP5QbL){0`u*@Qfwke+13^ UGpuMiD*ylh07*qoM6N<$g1d2qT>t<8 literal 0 HcmV?d00001 diff --git a/openpype/hosts/photoshop/api/extension/index.html b/openpype/hosts/photoshop/api/extension/index.html new file mode 100644 index 0000000000..501e753c0b --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/index.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py new file mode 100644 index 0000000000..36347b8ce0 --- /dev/null +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -0,0 +1,315 @@ +import os +import subprocess +import collections +import logging +import asyncio +import functools + +from wsrpc_aiohttp import ( + WebSocketRoute, + WebSocketAsync +) + +from Qt import QtCore + +from openpype.tools.utils import host_tools + +from avalon import api +from avalon.tools.webserver.app import WebServerTool + +from .ws_stub import PhotoshopServerStub + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +class ConnectionNotEstablishedYet(Exception): + pass + + +def stub(): + """ + Convenience function to get server RPC stub to call methods directed + for host (Photoshop). + It expects already created connection, started from client. + Currently created when panel is opened (PS: Window>Extensions>Avalon) + :return: where functions could be called from + """ + ps_stub = PhotoshopServerStub() + if not ps_stub.client: + raise ConnectionNotEstablishedYet("Connection is not created yet") + + return ps_stub + + +def show_tool_by_name(tool_name): + kwargs = {} + if tool_name == "loader": + kwargs["use_context"] = True + + host_tools.show_tool_by_name(tool_name, **kwargs) + + +class ProcessLauncher(QtCore.QObject): + route_name = "Photoshop" + _main_thread_callbacks = collections.deque() + + def __init__(self, subprocess_args): + self._subprocess_args = subprocess_args + self._log = None + + super(ProcessLauncher, self).__init__() + + # Keep track if launcher was already started + self._started = False + + self._process = None + self._websocket_server = None + + start_process_timer = QtCore.QTimer() + start_process_timer.setInterval(100) + + loop_timer = QtCore.QTimer() + loop_timer.setInterval(200) + + start_process_timer.timeout.connect(self._on_start_process_timer) + loop_timer.timeout.connect(self._on_loop_timer) + + self._start_process_timer = start_process_timer + self._loop_timer = loop_timer + + @property + def log(self): + if self._log is None: + from openpype.api import Logger + + self._log = Logger.get_logger("{}-launcher".format( + self.route_name)) + return self._log + + @property + def websocket_server_is_running(self): + if self._websocket_server is not None: + return self._websocket_server.is_running + return False + + @property + def is_process_running(self): + if self._process is not None: + return self._process.poll() is None + return False + + @property + def is_host_connected(self): + """Returns True if connected, False if app is not running at all.""" + if not self.is_process_running: + return False + + try: + + _stub = stub() + if _stub: + return True + except Exception: + pass + + return None + + @classmethod + def execute_in_main_thread(cls, callback): + cls._main_thread_callbacks.append(callback) + + def start(self): + if self._started: + return + self.log.info("Started launch logic of AfterEffects") + self._started = True + self._start_process_timer.start() + + def exit(self): + """ Exit whole application. """ + if self._start_process_timer.isActive(): + self._start_process_timer.stop() + if self._loop_timer.isActive(): + self._loop_timer.stop() + + if self._websocket_server is not None: + self._websocket_server.stop() + + if self._process: + self._process.kill() + self._process.wait() + + QtCore.QCoreApplication.exit() + + def _on_loop_timer(self): + # TODO find better way and catch errors + # Run only callbacks that are in queue at the moment + cls = self.__class__ + for _ in range(len(cls._main_thread_callbacks)): + if cls._main_thread_callbacks: + callback = cls._main_thread_callbacks.popleft() + callback() + + if not self.is_process_running: + self.log.info("Host process is not running. Closing") + self.exit() + + elif not self.websocket_server_is_running: + self.log.info("Websocket server is not running. Closing") + self.exit() + + def _on_start_process_timer(self): + # TODO add try except validations for each part in this method + # Start server as first thing + if self._websocket_server is None: + self._init_server() + return + + # TODO add waiting time + # Wait for webserver + if not self.websocket_server_is_running: + return + + # Start application process + if self._process is None: + self._start_process() + self.log.info("Waiting for host to connect") + return + + # TODO add waiting time + # Wait until host is connected + if self.is_host_connected: + self._start_process_timer.stop() + self._loop_timer.start() + elif ( + not self.is_process_running + or not self.websocket_server_is_running + ): + self.exit() + + def _init_server(self): + if self._websocket_server is not None: + return + + self.log.debug( + "Initialization of websocket server for host communication" + ) + + self._websocket_server = websocket_server = WebServerTool() + if websocket_server.port_occupied( + websocket_server.host_name, + websocket_server.port + ): + self.log.info( + "Server already running, sending actual context and exit." + ) + asyncio.run(websocket_server.send_context_change(self.route_name)) + self.exit() + return + + # Add Websocket route + websocket_server.add_route("*", "/ws/", WebSocketAsync) + # Add after effects route to websocket handler + + print("Adding {} route".format(self.route_name)) + WebSocketAsync.add_route( + self.route_name, PhotoshopRoute + ) + self.log.info("Starting websocket server for host communication") + websocket_server.start_server() + + def _start_process(self): + if self._process is not None: + return + self.log.info("Starting host process") + try: + self._process = subprocess.Popen( + self._subprocess_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception: + self.log.info("exce", exc_info=True) + self.exit() + + +class PhotoshopRoute(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + instance = None + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called Photoshop route") + self.instance = self + return kwargs + + # server functions + async def ping(self): + log.debug("someone called Photoshop route ping") + + # This method calls function on the client side + # client functions + async def set_context(self, project, asset, task): + """ + Sets 'project' and 'asset' to envs, eg. setting context + + Args: + project (str) + asset (str) + """ + log.info("Setting context change") + log.info("project {} asset {} ".format(project, asset)) + if project: + api.Session["AVALON_PROJECT"] = project + os.environ["AVALON_PROJECT"] = project + if asset: + api.Session["AVALON_ASSET"] = asset + os.environ["AVALON_ASSET"] = asset + if task: + api.Session["AVALON_TASK"] = task + os.environ["AVALON_TASK"] = task + + async def read(self): + log.debug("photoshop.read client calls server server calls " + "photoshop client") + return await self.socket.call('photoshop.read') + + # panel routes for tools + async def creator_route(self): + self._tool_route("creator") + + async def workfiles_route(self): + self._tool_route("workfiles") + + async def loader_route(self): + self._tool_route("loader") + + async def publish_route(self): + self._tool_route("publish") + + async def sceneinventory_route(self): + self._tool_route("sceneinventory") + + async def subsetmanager_route(self): + self._tool_route("subsetmanager") + + async def experimental_tools_route(self): + self._tool_route("experimental_tools") + + def _tool_route(self, _tool_name): + """The address accessed when clicking on the buttons.""" + + partial_method = functools.partial(show_tool_by_name, + _tool_name) + + ProcessLauncher.execute_in_main_thread(partial_method) + + # Required return statement. + return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py new file mode 100644 index 0000000000..bc1fb36cf3 --- /dev/null +++ b/openpype/hosts/photoshop/api/lib.py @@ -0,0 +1,76 @@ +import os +import sys +import contextlib +import logging +import traceback + +from Qt import QtWidgets + +from openpype.tools.utils import host_tools + +from openpype.lib.remote_publish import headless_publish + +from .launch_logic import ProcessLauncher, stub + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +def safe_excepthook(*args): + traceback.print_exception(*args) + + +def main(*subprocess_args): + from avalon import api, photoshop + + api.install(photoshop) + sys.excepthook = safe_excepthook + + # coloring in ConsoleTrayApp + os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" + app = QtWidgets.QApplication([]) + app.setQuitOnLastWindowClosed(False) + + launcher = ProcessLauncher(subprocess_args) + launcher.start() + + if os.environ.get("HEADLESS_PUBLISH"): + launcher.execute_in_main_thread(lambda: headless_publish( + log, + "ClosePS", + os.environ.get("IS_TEST"))) + elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): + save = False + if os.getenv("WORKFILES_SAVE_AS"): + save = True + + launcher.execute_in_main_thread( + lambda: host_tools.show_workfiles(save=save) + ) + + sys.exit(app.exec_()) + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context.""" + selection = stub().get_selected_layers() + try: + yield selection + finally: + stub().select_layers(selection) + + +@contextlib.contextmanager +def maintained_visibility(): + """Maintain visibility during context.""" + visibility = {} + layers = stub().get_layers() + for layer in layers: + visibility[layer.id] = layer.visible + try: + yield + finally: + for layer in layers: + stub().set_visible(layer.id, visibility[layer.id]) + pass diff --git a/openpype/hosts/photoshop/api/panel.PNG b/openpype/hosts/photoshop/api/panel.PNG new file mode 100644 index 0000000000000000000000000000000000000000..be5db3b8df08aa426b92de61eacc9afd00e93d45 GIT binary patch literal 8756 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py new file mode 100644 index 0000000000..ade144e6d4 --- /dev/null +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -0,0 +1,199 @@ +from .. import api, pipeline +from . import lib +from ..vendor import Qt + +import pyblish.api + + +def install(): + """Install Photoshop-specific functionality of avalon-core. + + This function is called automatically on calling `api.install(photoshop)`. + """ + print("Installing Avalon Photoshop...") + pyblish.api.register_host("photoshop") + + +def ls(): + """Yields containers from active Photoshop document + + This is the host-equivalent of api.ls(), but instead of listing + assets on disk, it lists assets already loaded in Photoshop; once loaded + they are called 'containers' + + Yields: + dict: container + + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + layers_meta = stub.get_layers_metadata() # minimalize calls to PS + for layer in stub.get_layers(): + data = stub.read(layer, layers_meta) + + # Skip non-tagged layers. + if not data: + continue + + # Filter to only containers. + if "container" not in data["id"]: + continue + + # Append transient data + data["objectName"] = layer.name.replace(stub.LOADED_ICON, '') + data["layer"] = layer + + yield data + + +def list_instances(): + """ + List all created instances from current workfile which + will be published. + + Pulls from File > File Info + + For SubsetManager + + Returns: + (list) of dictionaries matching instances format + """ + stub = _get_stub() + + if not stub: + return [] + + instances = [] + layers_meta = stub.get_layers_metadata() + if layers_meta: + for key, instance in layers_meta.items(): + if instance.get("schema") and \ + "container" in instance.get("schema"): + continue + + instance['uuid'] = key + instances.append(instance) + + return instances + + +def remove_instance(instance): + """ + Remove instance from current workfile metadata. + + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. + + For SubsetManager + + Args: + instance (dict): instance representation from subsetmanager model + """ + stub = _get_stub() + + if not stub: + return + + stub.remove_instance(instance.get("uuid")) + layer = stub.get_layer(instance.get("uuid")) + if layer: + stub.rename_layer(instance.get("uuid"), + layer.name.replace(stub.PUBLISH_ICON, '')) + + +def _get_stub(): + """ + Handle pulling stub from PS to run operations on host + Returns: + (PhotoshopServerStub) or None + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + return stub + + +class Creator(api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = Qt.QtWidgets.QMessageBox() + msg.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group + + +def containerise(name, + namespace, + layer, + context, + loader=None, + suffix="_CON"): + """Imprint layer with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + layer (PSItem): Layer to containerise + context (dict): Asset information + loader (str, optional): Name of loader used to produce this container. + suffix (str, optional): Suffix of container, defaults to `_CON`. + + Returns: + container (str): Name of container assembly + """ + layer.name = name + suffix + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace, + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + "members": [str(layer.id)] + } + stub = lib.stub() + stub.imprint(layer, data) + + return layer diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py new file mode 100644 index 0000000000..ddcd351b38 --- /dev/null +++ b/openpype/hosts/photoshop/api/workio.py @@ -0,0 +1,50 @@ +"""Host API required Work Files tool""" +import os + +from . import lib +from avalon import api + + +def _active_document(): + document_name = lib.stub().get_active_document_name() + if not document_name: + return None + + return document_name + + +def file_extensions(): + return api.HOST_WORKFILE_EXTENSIONS["photoshop"] + + +def has_unsaved_changes(): + if _active_document(): + return not lib.stub().is_saved() + + return False + + +def save_file(filepath): + _, ext = os.path.splitext(filepath) + lib.stub().saveAs(filepath, ext[1:], True) + + +def open_file(filepath): + lib.stub().open(filepath) + + return True + + +def current_file(): + try: + full_name = lib.stub().get_active_document_full_name() + if full_name and full_name != "null": + return os.path.normpath(full_name).replace("\\", "/") + except Exception: + pass + + return None + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py new file mode 100644 index 0000000000..f7bd03cdab --- /dev/null +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -0,0 +1,470 @@ +""" + Stub handling connection from server to client. + Used anywhere solution is calling client methods. +""" +import json +import sys +from wsrpc_aiohttp import WebSocketAsync +import attr + +from avalon.tools.webserver.app import WebServerTool + + +@attr.s +class PSItem(object): + """ + Object denoting layer or group item in PS. Each item is created in + PS by any Loader, but contains same fields, which are being used + in later processing. + """ + # metadata + id = attr.ib() # id created by AE, could be used for querying + name = attr.ib() # name of item + group = attr.ib(default=None) # item type (footage, folder, comp) + parents = attr.ib(factory=list) + visible = attr.ib(default=True) + type = attr.ib(default=None) + # all imported elements, single for + members = attr.ib(factory=list) + long_name = attr.ib(default=None) + color_code = attr.ib(default=None) # color code of layer + + +class PhotoshopServerStub: + """ + Stub for calling function on client (Photoshop js) side. + Expects that client is already connected (started when avalon menu + is opened). + 'self.websocketserver.call' is used as async wrapper + """ + PUBLISH_ICON = '\u2117 ' + LOADED_ICON = '\u25bc' + + def __init__(self): + self.websocketserver = WebServerTool.get_instance() + self.client = self.get_client() + + @staticmethod + def get_client(): + """ + Return first connected client to WebSocket + TODO implement selection by Route + :return: client + """ + clients = WebSocketAsync.get_clients() + client = None + if len(clients) > 0: + key = list(clients.keys())[0] + client = clients.get(key) + + return client + + def open(self, path): + """ + Open file located at 'path' (local). + Args: + path(string): file path locally + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.open', path=path) + ) + + def read(self, layer, layers_meta=None): + """ + Parses layer metadata from Headline field of active document + Args: + layer: (PSItem) + layers_meta: full list from Headline (for performance in loops) + Returns: + """ + if layers_meta is None: + layers_meta = self.get_layers_metadata() + + return layers_meta.get(str(layer.id)) + + def imprint(self, layer, data, all_layers=None, layers_meta=None): + """ + Save layer metadata to Headline field of active document + + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances + + Args: + layer (PSItem): + data(string): json representation for single layer + all_layers (list of PSItem): for performance, could be + injected for usage in loop, if not, single call will be + triggered + layers_meta(string): json representation from Headline + (for performance - provide only if imprint is in + loop - value should be same) + Returns: None + """ + if not layers_meta: + layers_meta = self.get_layers_metadata() + + # json.dumps writes integer values in a dictionary to string, so + # anticipating it here. + if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: + if data: + layers_meta[str(layer.id)].update(data) + else: + layers_meta.pop(str(layer.id)) + else: + layers_meta[str(layer.id)] = data + + # Ensure only valid ids are stored. + if not all_layers: + all_layers = self.get_layers() + layer_ids = [layer.id for layer in all_layers] + cleaned_data = [] + + for id in layers_meta: + if int(id) in layer_ids: + cleaned_data.append(layers_meta[id]) + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_layers(self): + """ + Returns JSON document with all(?) layers in active document. + + Returns: + Format of tuple: { 'id':'123', + 'name': 'My Layer 1', + 'type': 'GUIDE'|'FG'|'BG'|'OBJ' + 'visible': 'true'|'false' + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_layers')) + + return self._to_records(res) + + def get_layer(self, layer_id): + """ + Returns PSItem for specific 'layer_id' or None if not found + Args: + layer_id (string): unique layer id, stored in 'uuid' field + + Returns: + (PSItem) or None + """ + layers = self.get_layers() + for layer in layers: + if str(layer.id) == str(layer_id): + return layer + + def get_layers_in_layers(self, layers): + """ + Return all layers that belong to layers (might be groups). + Args: + layers : + Returns: + """ + all_layers = self.get_layers() + ret = [] + parent_ids = set([lay.id for lay in layers]) + + for layer in all_layers: + parents = set(layer.parents) + if len(parent_ids & parents) > 0: + ret.append(layer) + if layer.id in parent_ids: + ret.append(layer) + + return ret + + def create_group(self, name): + """ + Create new group (eg. LayerSet) + Returns: + """ + enhanced_name = self.PUBLISH_ICON + name + ret = self.websocketserver.call(self.client.call + ('Photoshop.create_group', + name=enhanced_name)) + # create group on PS is asynchronous, returns only id + return PSItem(id=ret, name=name, group=True) + + def group_selected_layers(self, name): + """ + Group selected layers into new LayerSet (eg. group) + Returns: (Layer) + """ + enhanced_name = self.PUBLISH_ICON + name + res = self.websocketserver.call(self.client.call + ('Photoshop.group_selected_layers', + name=enhanced_name) + ) + res = self._to_records(res) + if res: + rec = res.pop() + rec.name = rec.name.replace(self.PUBLISH_ICON, '') + return rec + raise ValueError("No group record returned") + + def get_selected_layers(self): + """ + Get a list of actually selected layers + Returns: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_selected_layers')) + return self._to_records(res) + + def select_layers(self, layers): + """ + Selects specified layers in Photoshop by its ids + Args: + layers: + Returns: None + """ + layers_id = [str(lay.id) for lay in layers] + self.websocketserver.call(self.client.call + ('Photoshop.select_layers', + layers=json.dumps(layers_id)) + ) + + def get_active_document_full_name(self): + """ + Returns full name with path of active document via ws call + Returns(string): full path with name + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_active_document_full_name')) + + return res + + def get_active_document_name(self): + """ + Returns just a name of active document via ws call + Returns(string): file name + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_active_document_name')) + + return res + + def is_saved(self): + """ + Returns true if no changes in active document + Returns: + """ + return self.websocketserver.call(self.client.call + ('Photoshop.is_saved')) + + def save(self): + """ + Saves active document + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.save')) + + def saveAs(self, image_path, ext, as_copy): + """ + Saves active document to psd (copy) or png or jpg + Args: + image_path(string): full local path + ext: + as_copy: + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy)) + + def set_visible(self, layer_id, visibility): + """ + Set layer with 'layer_id' to 'visibility' + Args: + layer_id: + visibility: + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility)) + + def get_layers_metadata(self): + """ + Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) + + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. + """ + layers_data = {} + res = self.websocketserver.call(self.client.call('Photoshop.read')) + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + pass + # format of metadata changed from {} to [] because of standardization + # keep current implementation logic as its working + if not isinstance(layers_data, dict): + temp_layers_meta = {} + for layer_meta in layers_data: + layer_id = layer_meta.get("uuid") or \ + (layer_meta.get("members")[0]) + temp_layers_meta[layer_id] = layer_meta + layers_data = temp_layers_meta + else: + # legacy version of metadata + for layer_id, layer_meta in layers_data.items(): + if layer_meta.get("schema") != "openpype:container-2.0": + layer_meta["uuid"] = str(layer_id) + else: + layer_meta["members"] = [str(layer_id)] + + return layers_data + + def import_smart_object(self, path, layer_name, as_reference=False): + """ + Import the file at `path` as a smart object to active document. + + Args: + path (str): File path to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + as_reference (bool): pull in content or reference + """ + enhanced_name = self.LOADED_ICON + layer_name + res = self.websocketserver.call(self.client.call + ('Photoshop.import_smart_object', + path=path, name=enhanced_name, + as_reference=as_reference + )) + rec = self._to_records(res).pop() + if rec: + rec.name = rec.name.replace(self.LOADED_ICON, '') + return rec + + def replace_smart_object(self, layer, path, layer_name): + """ + Replace the smart object `layer` with file at `path` + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + + Args: + layer (PSItem): + path (str): File to import. + """ + enhanced_name = self.LOADED_ICON + layer_name + self.websocketserver.call(self.client.call + ('Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, name=enhanced_name)) + + def delete_layer(self, layer_id): + """ + Deletes specific layer by it's id. + Args: + layer_id (int): id of layer to delete + """ + self.websocketserver.call(self.client.call + ('Photoshop.delete_layer', + layer_id=layer_id)) + + def rename_layer(self, layer_id, name): + """ + Renames specific layer by it's id. + Args: + layer_id (int): id of layer to delete + name (str): new name + """ + self.websocketserver.call(self.client.call + ('Photoshop.rename_layer', + layer_id=layer_id, + name=name)) + + def remove_instance(self, instance_id): + cleaned_data = {} + + for key, instance in self.get_layers_metadata().items(): + if key != instance_id: + cleaned_data[key] = instance + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_extension_version(self): + """Returns version number of installed extension.""" + return self.websocketserver.call(self.client.call + ('Photoshop.get_extension_version')) + + def close(self): + """Shutting down PS and process too. + + For webpublishing only. + """ + # TODO change client.call to method with checks for client + self.websocketserver.call(self.client.call('Photoshop.close')) + + def _to_records(self, res): + """ + Converts string json representation into list of PSItem for + dot notation access to work. + Args: + res (string): valid json + Returns: + + """ + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + raise ValueError("Received broken JSON {}".format(res)) + ret = [] + + # convert to AEItem to use dot donation + if isinstance(layers_data, dict): + layers_data = [layers_data] + for d in layers_data: + # currently implemented and expected fields + item = PSItem(d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code")) + + ret.append(item) + return ret From 3ca400949134fc604744086ae74c8b16bd0d8757 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:00:53 +0100 Subject: [PATCH 136/151] removed empty hooks --- openpype/hosts/photoshop/hooks/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/hosts/photoshop/hooks/__init__.py diff --git a/openpype/hosts/photoshop/hooks/__init__.py b/openpype/hosts/photoshop/hooks/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From c09d832c73db12455ec56d1429510f5c43772d56 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:04:35 +0100 Subject: [PATCH 137/151] use openpype logger --- openpype/hosts/photoshop/api/launch_logic.py | 13 +++++-------- openpype/hosts/photoshop/api/lib.py | 5 ++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 36347b8ce0..8e0d00636a 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -1,7 +1,6 @@ import os import subprocess import collections -import logging import asyncio import functools @@ -12,6 +11,7 @@ from wsrpc_aiohttp import ( from Qt import QtCore +from openpype.api import Logger from openpype.tools.utils import host_tools from avalon import api @@ -19,8 +19,7 @@ from avalon.tools.webserver.app import WebServerTool from .ws_stub import PhotoshopServerStub -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) +log = Logger.get_logger(__name__) class ConnectionNotEstablishedYet(Exception): @@ -81,10 +80,9 @@ class ProcessLauncher(QtCore.QObject): @property def log(self): if self._log is None: - from openpype.api import Logger - - self._log = Logger.get_logger("{}-launcher".format( - self.route_name)) + self._log = Logger.get_logger( + "{}-launcher".format(self.route_name) + ) return self._log @property @@ -106,7 +104,6 @@ class ProcessLauncher(QtCore.QObject): return False try: - _stub = stub() if _stub: return True diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index bc1fb36cf3..0938febf43 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,19 +1,18 @@ import os import sys import contextlib -import logging import traceback from Qt import QtWidgets from openpype.tools.utils import host_tools +from openpype.api import Logger from openpype.lib.remote_publish import headless_publish from .launch_logic import ProcessLauncher, stub -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) +log = Logger.get_logger(__name__) def safe_excepthook(*args): From 1a623d6ee2a17246a881a2d214e9191e7a54aeab Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:05:05 +0100 Subject: [PATCH 138/151] formatting changes --- openpype/hosts/photoshop/api/workio.py | 5 +- openpype/hosts/photoshop/api/ws_stub.py | 347 +++++++++++++----------- 2 files changed, 189 insertions(+), 163 deletions(-) diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py index ddcd351b38..0bf3ed2bd9 100644 --- a/openpype/hosts/photoshop/api/workio.py +++ b/openpype/hosts/photoshop/api/workio.py @@ -1,8 +1,9 @@ """Host API required Work Files tool""" import os +import avalon.api + from . import lib -from avalon import api def _active_document(): @@ -14,7 +15,7 @@ def _active_document(): def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["photoshop"] + return avalon.api.HOST_WORKFILE_EXTENSIONS["photoshop"] def has_unsaved_changes(): diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index f7bd03cdab..b8f66332c6 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -2,10 +2,10 @@ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ -import json import sys -from wsrpc_aiohttp import WebSocketAsync +import json import attr +from wsrpc_aiohttp import WebSocketAsync from avalon.tools.webserver.app import WebServerTool @@ -60,19 +60,19 @@ class PhotoshopServerStub: return client def open(self, path): - """ - Open file located at 'path' (local). + """Open file located at 'path' (local). + Args: path(string): file path locally Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.open', path=path) - ) + self.websocketserver.call( + self.client.call('Photoshop.open', path=path) + ) def read(self, layer, layers_meta=None): - """ - Parses layer metadata from Headline field of active document + """Parses layer metadata from Headline field of active document. + Args: layer: (PSItem) layers_meta: full list from Headline (for performance in loops) @@ -84,30 +84,29 @@ class PhotoshopServerStub: return layers_meta.get(str(layer.id)) def imprint(self, layer, data, all_layers=None, layers_meta=None): - """ - Save layer metadata to Headline field of active document + """Save layer metadata to Headline field of active document - Stores metadata in format: - [{ - "active":true, - "subset":"imageBG", - "family":"image", - "id":"pyblish.avalon.instance", - "asset":"Town", - "uuid": "8" - }] - for created instances - OR - [{ - "schema": "openpype:container-2.0", - "id": "pyblish.avalon.instance", - "name": "imageMG", - "namespace": "Jungle_imageMG_001", - "loader": "ImageLoader", - "representation": "5fbfc0ee30a946093c6ff18a", - "members": [ - "40" - ] - }] - for loaded instances + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances Args: layer (PSItem): @@ -139,19 +138,18 @@ class PhotoshopServerStub: layer_ids = [layer.id for layer in all_layers] cleaned_data = [] - for id in layers_meta: - if int(id) in layer_ids: - cleaned_data.append(layers_meta[id]) + for layer_id in layers_meta: + if int(layer_id) in layer_ids: + cleaned_data.append(layers_meta[layer_id]) payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) def get_layers(self): - """ - Returns JSON document with all(?) layers in active document. + """Returns JSON document with all(?) layers in active document. Returns: Format of tuple: { 'id':'123', @@ -159,8 +157,9 @@ class PhotoshopServerStub: 'type': 'GUIDE'|'FG'|'BG'|'OBJ' 'visible': 'true'|'false' """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_layers')) + res = self.websocketserver.call( + self.client.call('Photoshop.get_layers') + ) return self._to_records(res) @@ -179,11 +178,13 @@ class PhotoshopServerStub: return layer def get_layers_in_layers(self, layers): - """ - Return all layers that belong to layers (might be groups). + """Return all layers that belong to layers (might be groups). + Args: layers : - Returns: + + Returns: + """ all_layers = self.get_layers() ret = [] @@ -199,27 +200,30 @@ class PhotoshopServerStub: return ret def create_group(self, name): - """ - Create new group (eg. LayerSet) - Returns: + """Create new group (eg. LayerSet) + + Returns: + """ enhanced_name = self.PUBLISH_ICON + name - ret = self.websocketserver.call(self.client.call - ('Photoshop.create_group', - name=enhanced_name)) + ret = self.websocketserver.call( + self.client.call('Photoshop.create_group', name=enhanced_name) + ) # create group on PS is asynchronous, returns only id return PSItem(id=ret, name=name, group=True) def group_selected_layers(self, name): - """ - Group selected layers into new LayerSet (eg. group) - Returns: (Layer) + """Group selected layers into new LayerSet (eg. group) + + Returns: + (Layer) """ enhanced_name = self.PUBLISH_ICON + name - res = self.websocketserver.call(self.client.call - ('Photoshop.group_selected_layers', - name=enhanced_name) - ) + res = self.websocketserver.call( + self.client.call( + 'Photoshop.group_selected_layers', name=enhanced_name + ) + ) res = self._to_records(res) if res: rec = res.pop() @@ -228,103 +232,112 @@ class PhotoshopServerStub: raise ValueError("No group record returned") def get_selected_layers(self): - """ - Get a list of actually selected layers + """Get a list of actually selected layers. + Returns: """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_selected_layers')) + res = self.websocketserver.call( + self.client.call('Photoshop.get_selected_layers') + ) return self._to_records(res) def select_layers(self, layers): - """ - Selects specified layers in Photoshop by its ids + """Selects specified layers in Photoshop by its ids. + Args: layers: - Returns: None """ layers_id = [str(lay.id) for lay in layers] - self.websocketserver.call(self.client.call - ('Photoshop.select_layers', - layers=json.dumps(layers_id)) - ) + self.websocketserver.call( + self.client.call( + 'Photoshop.select_layers', + layers=json.dumps(layers_id) + ) + ) def get_active_document_full_name(self): - """ - Returns full name with path of active document via ws call - Returns(string): full path with name + """Returns full name with path of active document via ws call + + Returns(string): + full path with name """ res = self.websocketserver.call( - self.client.call('Photoshop.get_active_document_full_name')) + self.client.call('Photoshop.get_active_document_full_name') + ) return res def get_active_document_name(self): - """ - Returns just a name of active document via ws call - Returns(string): file name - """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_active_document_name')) + """Returns just a name of active document via ws call - return res + Returns(string): + file name + """ + return self.websocketserver.call( + self.client.call('Photoshop.get_active_document_name') + ) def is_saved(self): + """Returns true if no changes in active document + + Returns: + """ - Returns true if no changes in active document - Returns: - """ - return self.websocketserver.call(self.client.call - ('Photoshop.is_saved')) + return self.websocketserver.call( + self.client.call('Photoshop.is_saved') + ) def save(self): - """ - Saves active document - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.save')) + """Saves active document""" + self.websocketserver.call( + self.client.call('Photoshop.save') + ) def saveAs(self, image_path, ext, as_copy): - """ - Saves active document to psd (copy) or png or jpg + """Saves active document to psd (copy) or png or jpg + Args: image_path(string): full local path ext: as_copy: Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.saveAs', - image_path=image_path, - ext=ext, - as_copy=as_copy)) + self.websocketserver.call( + self.client.call( + 'Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy + ) + ) def set_visible(self, layer_id, visibility): - """ - Set layer with 'layer_id' to 'visibility' + """Set layer with 'layer_id' to 'visibility' + Args: layer_id: visibility: Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.set_visible', - layer_id=layer_id, - visibility=visibility)) + self.websocketserver.call( + self.client.call( + 'Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility + ) + ) def get_layers_metadata(self): - """ - Reads layers metadata from Headline from active document in PS. - (Headline accessible by File > File Info) + """Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) - Returns: - (string): - json documents - example: - {"8":{"active":true,"subset":"imageBG", - "family":"image","id":"pyblish.avalon.instance", - "asset":"Town"}} - 8 is layer(group) id - used for deletion, update etc. + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. """ layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) @@ -337,8 +350,10 @@ class PhotoshopServerStub: if not isinstance(layers_data, dict): temp_layers_meta = {} for layer_meta in layers_data: - layer_id = layer_meta.get("uuid") or \ - (layer_meta.get("members")[0]) + layer_id = layer_meta.get("uuid") + if not layer_id: + layer_id = layer_meta.get("members")[0] + temp_layers_meta[layer_id] = layer_meta layers_data = temp_layers_meta else: @@ -352,8 +367,7 @@ class PhotoshopServerStub: return layers_data def import_smart_object(self, path, layer_name, as_reference=False): - """ - Import the file at `path` as a smart object to active document. + """Import the file at `path` as a smart object to active document. Args: path (str): File path to import. @@ -362,53 +376,62 @@ class PhotoshopServerStub: as_reference (bool): pull in content or reference """ enhanced_name = self.LOADED_ICON + layer_name - res = self.websocketserver.call(self.client.call - ('Photoshop.import_smart_object', - path=path, name=enhanced_name, - as_reference=as_reference - )) + res = self.websocketserver.call( + self.client.call( + 'Photoshop.import_smart_object', + path=path, + name=enhanced_name, + as_reference=as_reference + ) + ) rec = self._to_records(res).pop() if rec: rec.name = rec.name.replace(self.LOADED_ICON, '') return rec def replace_smart_object(self, layer, path, layer_name): - """ - Replace the smart object `layer` with file at `path` - layer_name (str): Unique layer name to differentiate how many times - same smart object was loaded + """Replace the smart object `layer` with file at `path` Args: layer (PSItem): path (str): File to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded """ enhanced_name = self.LOADED_ICON + layer_name - self.websocketserver.call(self.client.call - ('Photoshop.replace_smart_object', - layer_id=layer.id, - path=path, name=enhanced_name)) + self.websocketserver.call( + self.client.call( + 'Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, + name=enhanced_name + ) + ) def delete_layer(self, layer_id): - """ - Deletes specific layer by it's id. + """Deletes specific layer by it's id. + Args: layer_id (int): id of layer to delete """ - self.websocketserver.call(self.client.call - ('Photoshop.delete_layer', - layer_id=layer_id)) + self.websocketserver.call( + self.client.call('Photoshop.delete_layer', layer_id=layer_id) + ) def rename_layer(self, layer_id, name): - """ - Renames specific layer by it's id. + """Renames specific layer by it's id. + Args: layer_id (int): id of layer to delete name (str): new name """ - self.websocketserver.call(self.client.call - ('Photoshop.rename_layer', - layer_id=layer_id, - name=name)) + self.websocketserver.call( + self.client.call( + 'Photoshop.rename_layer', + layer_id=layer_id, + name=name + ) + ) def remove_instance(self, instance_id): cleaned_data = {} @@ -419,14 +442,15 @@ class PhotoshopServerStub: payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) def get_extension_version(self): """Returns version number of installed extension.""" - return self.websocketserver.call(self.client.call - ('Photoshop.get_extension_version')) + return self.websocketserver.call( + self.client.call('Photoshop.get_extension_version') + ) def close(self): """Shutting down PS and process too. @@ -437,11 +461,12 @@ class PhotoshopServerStub: self.websocketserver.call(self.client.call('Photoshop.close')) def _to_records(self, res): - """ - Converts string json representation into list of PSItem for - dot notation access to work. + """Converts string json representation into list of PSItem for + dot notation access to work. + Args: res (string): valid json + Returns: """ @@ -456,15 +481,15 @@ class PhotoshopServerStub: layers_data = [layers_data] for d in layers_data: # currently implemented and expected fields - item = PSItem(d.get('id'), - d.get('name'), - d.get('group'), - d.get('parents'), - d.get('visible'), - d.get('type'), - d.get('members'), - d.get('long_name'), - d.get("color_code")) - - ret.append(item) + ret.append(PSItem( + d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code") + )) return ret From faf7e7bfebb0412ca360ab22373e75b53db612f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:06:41 +0100 Subject: [PATCH 139/151] extended main thread exection --- openpype/hosts/photoshop/api/launch_logic.py | 71 +++++++++++++++++--- openpype/hosts/photoshop/api/lib.py | 8 ++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 8e0d00636a..16a1d23244 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -2,7 +2,6 @@ import os import subprocess import collections import asyncio -import functools from wsrpc_aiohttp import ( WebSocketRoute, @@ -26,6 +25,61 @@ class ConnectionNotEstablishedYet(Exception): pass +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + + def __init__(self, callback, *args, **kwargs): + self._done = False + self._exception = self.not_set + self._result = self.not_set + self._callback = callback + self._args = args + self._kwargs = kwargs + + @property + def done(self): + return self._done + + @property + def exception(self): + return self._exception + + @property + def result(self): + return self._result + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + log.debug("Executing process in main thread") + if self.done: + log.warning("- item is already processed") + return + + log.info("Running callback: {}".format(str(self._callback))) + try: + result = self._callback(*self._args, **self._kwargs) + self._result = result + + except Exception as exc: + self._exception = exc + + finally: + self._done = True + + def stub(): """ Convenience function to get server RPC stub to call methods directed @@ -113,8 +167,10 @@ class ProcessLauncher(QtCore.QObject): return None @classmethod - def execute_in_main_thread(cls, callback): - cls._main_thread_callbacks.append(callback) + def execute_in_main_thread(cls, callback, *args, **kwargs): + item = MainThreadItem(callback, *args, **kwargs) + cls._main_thread_callbacks.append(item) + return item def start(self): if self._started: @@ -145,8 +201,8 @@ class ProcessLauncher(QtCore.QObject): cls = self.__class__ for _ in range(len(cls._main_thread_callbacks)): if cls._main_thread_callbacks: - callback = cls._main_thread_callbacks.popleft() - callback() + item = cls._main_thread_callbacks.popleft() + item.execute() if not self.is_process_running: self.log.info("Host process is not running. Closing") @@ -303,10 +359,7 @@ class PhotoshopRoute(WebSocketRoute): def _tool_route(self, _tool_name): """The address accessed when clicking on the buttons.""" - partial_method = functools.partial(show_tool_by_name, - _tool_name) - - ProcessLauncher.execute_in_main_thread(partial_method) + ProcessLauncher.execute_in_main_thread(show_tool_by_name, _tool_name) # Required return statement. return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 0938febf43..4c80c04cae 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -34,17 +34,19 @@ def main(*subprocess_args): launcher.start() if os.environ.get("HEADLESS_PUBLISH"): - launcher.execute_in_main_thread(lambda: headless_publish( + launcher.execute_in_main_thread( + headless_publish, log, "ClosePS", - os.environ.get("IS_TEST"))) + os.environ.get("IS_TEST") + ) elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): save = False if os.getenv("WORKFILES_SAVE_AS"): save = True launcher.execute_in_main_thread( - lambda: host_tools.show_workfiles(save=save) + host_tools.show_workfiles, save=save ) sys.exit(app.exec_()) From 1b36e7e73039169164b98cf1408134f43b634541 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:07:29 +0100 Subject: [PATCH 140/151] added code from openpype __init__.py to pipeline.py --- openpype/hosts/photoshop/api/pipeline.py | 126 +++++++++++++++++------ 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ade144e6d4..ed7b94e249 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -1,8 +1,61 @@ -from .. import api, pipeline -from . import lib -from ..vendor import Qt +import os +import sys +from Qt import QtWidgets import pyblish.api +import avalon.api +from avalon import pipeline, io + +from openpype.api import Logger +import openpype.hosts.photoshop + +from . import lib + +log = Logger.get_logger(__name__) + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + + +def check_inventory(): + if not lib.any_outdated(): + return + + host = avalon.api.registered_host() + outdated_containers = [] + for container in host.ls(): + representation = container['representation'] + representation_doc = io.find_one( + { + "_id": io.ObjectId(representation), + "type": "representation" + }, + projection={"parent": True} + ) + if representation_doc and not lib.is_latest(representation_doc): + outdated_containers.append(container) + + # Warn about outdated containers. + print("Starting new QApplication..") + + message_box = QtWidgets.QMessageBox() + message_box.setIcon(QtWidgets.QMessageBox.Warning) + msg = "There are outdated containers in the scene." + message_box.setText(msg) + message_box.exec_() + + +def on_application_launch(): + check_inventory() + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle layer visibility on instance toggles.""" + instance[0].Visible = new_value def install(): @@ -10,9 +63,26 @@ def install(): This function is called automatically on calling `api.install(photoshop)`. """ - print("Installing Avalon Photoshop...") + log.info("Installing OpenPype Photoshop...") pyblish.api.register_host("photoshop") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + log.info(PUBLISH_PATH) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled + ) + + avalon.api.on("application.launched", on_application_launch) + + +def uninstall(): + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + def ls(): """Yields containers from active Photoshop document @@ -54,16 +124,14 @@ def ls(): def list_instances(): - """ - List all created instances from current workfile which - will be published. + """List all created instances to publish from current workfile. - Pulls from File > File Info + Pulls from File > File Info - For SubsetManager + For SubsetManager - Returns: - (list) of dictionaries matching instances format + Returns: + (list) of dictionaries matching instances format """ stub = _get_stub() @@ -74,8 +142,8 @@ def list_instances(): layers_meta = stub.get_layers_metadata() if layers_meta: for key, instance in layers_meta.items(): - if instance.get("schema") and \ - "container" in instance.get("schema"): + schema = instance.get("schema") + if schema and "container" in schema: continue instance['uuid'] = key @@ -85,16 +153,15 @@ def list_instances(): def remove_instance(instance): - """ - Remove instance from current workfile metadata. + """Remove instance from current workfile metadata. - Updates metadata of current file in File > File Info and removes - icon highlight on group layer. + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. - For SubsetManager + For SubsetManager - Args: - instance (dict): instance representation from subsetmanager model + Args: + instance (dict): instance representation from subsetmanager model """ stub = _get_stub() @@ -109,8 +176,8 @@ def remove_instance(instance): def _get_stub(): - """ - Handle pulling stub from PS to run operations on host + """Handle pulling stub from PS to run operations on host + Returns: (PhotoshopServerStub) or None """ @@ -126,7 +193,7 @@ def _get_stub(): return stub -class Creator(api.Creator): +class Creator(avalon.api.Creator): """Creator plugin to create instances in Photoshop A LayerSet is created to support any number of layers in an instance. If @@ -140,8 +207,8 @@ class Creator(api.Creator): stub = lib.stub() # only after Photoshop is up for layer in stub.get_layers(): if self.name.lower() == layer.Name.lower(): - msg = Qt.QtWidgets.QMessageBox() - msg.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) msg.setText(msg) msg.exec_() return False @@ -160,12 +227,9 @@ class Creator(api.Creator): return group -def containerise(name, - namespace, - layer, - context, - loader=None, - suffix="_CON"): +def containerise( + name, namespace, layer, context, loader=None, suffix="_CON" +): """Imprint layer with metadata Containerisation enables a tracking of version, author and origin From 56446e0c4c4dc51ebbb600fb4656d7a1a851f0cf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:07:45 +0100 Subject: [PATCH 141/151] changed registered host --- openpype/hosts/photoshop/api/lib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 4c80c04cae..509c5d5c48 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,13 +1,15 @@ import os import sys +import re import contextlib import traceback from Qt import QtWidgets -from openpype.tools.utils import host_tools +import avalon.api from openpype.api import Logger +from openpype.tools.utils import host_tools from openpype.lib.remote_publish import headless_publish from .launch_logic import ProcessLauncher, stub @@ -20,9 +22,9 @@ def safe_excepthook(*args): def main(*subprocess_args): - from avalon import api, photoshop + from openpype.hosts.photoshop import api - api.install(photoshop) + avalon.api.install(api) sys.excepthook = safe_excepthook # coloring in ConsoleTrayApp From c1d6eaa5f948f823eea373b723a405176c27278b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:08:51 +0100 Subject: [PATCH 142/151] changed import of photoshop in plugins --- openpype/hosts/photoshop/plugins/create/create_image.py | 2 +- openpype/hosts/photoshop/plugins/load/load_image.py | 3 ++- .../hosts/photoshop/plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/photoshop/plugins/load/load_reference.py | 3 ++- openpype/hosts/photoshop/plugins/publish/closePS.py | 2 +- .../hosts/photoshop/plugins/publish/collect_current_file.py | 2 +- .../photoshop/plugins/publish/collect_extension_version.py | 2 +- .../hosts/photoshop/plugins/publish/collect_instances.py | 2 +- .../photoshop/plugins/publish/collect_remote_instances.py | 5 +++-- openpype/hosts/photoshop/plugins/publish/collect_workfile.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_image.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- .../hosts/photoshop/plugins/publish/extract_save_scene.py | 2 +- .../hosts/photoshop/plugins/publish/increment_workfile.py | 2 +- .../photoshop/plugins/publish/validate_instance_asset.py | 2 +- openpype/hosts/photoshop/plugins/publish/validate_naming.py | 2 +- 16 files changed, 20 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 657d41aa93..cf41bb4020 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,6 +1,6 @@ from Qt import QtWidgets import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CreateImage(openpype.api.Creator): diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 981a1ed204..3756eba54e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -1,6 +1,7 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 8704627b12..158bdc2940 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,12 +1,12 @@ import os from avalon import api -from avalon import photoshop from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse from openpype.lib import Anatomy from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name +from openpype.hosts.photoshop import api as photoshop stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0cb4e4a69f..844bb2463a 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -1,7 +1,8 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py index 2f0eab0ee5..b4ded96001 100644 --- a/openpype/hosts/photoshop/plugins/publish/closePS.py +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -4,7 +4,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ClosePS(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py index 4d4829555e..5daf47c6ac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py @@ -2,7 +2,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectCurrentFile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py index f07ff0b0ff..64c99b4fc1 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py @@ -2,7 +2,7 @@ import os import re import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectExtensionVersion(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 5390df768b..f67cc0cbac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,6 +1,6 @@ import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py index c76e15484e..e264d04d9f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -1,10 +1,11 @@ -import pyblish.api import os import re -from avalon import photoshop +import pyblish.api + from openpype.lib import prepare_template_data from openpype.lib.plugin_tools import parse_json +from openpype.hosts.photoshop import api as photoshop class CollectRemoteInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 88817c3969..db1ede14d5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,5 +1,5 @@ -import pyblish.api import os +import pyblish.api class CollectWorkfile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index ae9892e290..2ba81e0bac 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -1,7 +1,7 @@ import os import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractImage(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 8c4d05b282..1ad442279a 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -2,7 +2,7 @@ import os import openpype.api import openpype.lib -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractReview(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py index 0180640c90..03086f389f 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py @@ -1,5 +1,5 @@ import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractSaveScene(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py index 709fb988fc..92132c393b 100644 --- a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py @@ -3,7 +3,7 @@ import pyblish.api from openpype.action import get_errored_plugins_from_data from openpype.lib import version_up -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class IncrementWorkfile(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py index 4dc1972074..ebe9cc21ea 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py @@ -1,7 +1,7 @@ from avalon import api import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateInstanceAssetRepair(pyblish.api.Action): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index 1635096f4b..b40e44d016 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -2,7 +2,7 @@ import re import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateNamingRepair(pyblish.api.Action): From 551d40b62487618f2c821c40b034c93c92ae2bce Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:09:08 +0100 Subject: [PATCH 143/151] changed from where is 'main' imported --- openpype/scripts/non_python_host_launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 32c4b23f4f..6b17e6a037 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -81,7 +81,7 @@ def main(argv): host_name = os.environ["AVALON_APP"].lower() if host_name == "photoshop": - from avalon.photoshop.lib import main + from openpype.hosts.photoshop.api.lib import main elif host_name == "aftereffects": from avalon.aftereffects.lib import main elif host_name == "harmony": From d7bc9c4124c38b4f5ae804508939afaf48f572d1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:17:50 +0100 Subject: [PATCH 144/151] moved lib functions outside of plugins dir --- openpype/hosts/photoshop/api/__init__.py | 7 ++++++- openpype/hosts/photoshop/{plugins/lib.py => api/plugin.py} | 0 openpype/hosts/photoshop/plugins/__init__.py | 0 openpype/hosts/photoshop/plugins/load/load_image.py | 2 +- .../photoshop/plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/photoshop/plugins/load/load_reference.py | 2 +- 6 files changed, 9 insertions(+), 4 deletions(-) rename openpype/hosts/photoshop/{plugins/lib.py => api/plugin.py} (100%) delete mode 100644 openpype/hosts/photoshop/plugins/__init__.py diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 43756b9ee4..a25dfe7044 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -12,7 +12,9 @@ from .pipeline import ( install, containerise ) - +from .plugin import ( + get_unique_layer_name +) from .workio import ( file_extensions, has_unsaved_changes, @@ -38,6 +40,9 @@ __all__ = [ "install", "containerise", + # Plugin + "get_unique_layer_name", + # workfiles "file_extensions", "has_unsaved_changes", diff --git a/openpype/hosts/photoshop/plugins/lib.py b/openpype/hosts/photoshop/api/plugin.py similarity index 100% rename from openpype/hosts/photoshop/plugins/lib.py rename to openpype/hosts/photoshop/api/plugin.py diff --git a/openpype/hosts/photoshop/plugins/__init__.py b/openpype/hosts/photoshop/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 3756eba54e..25f47b0257 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -2,8 +2,8 @@ import re from avalon import api from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 158bdc2940..bbf4c60242 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -5,8 +5,8 @@ from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse from openpype.lib import Anatomy -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 844bb2463a..0f3c148155 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -3,7 +3,7 @@ import re from avalon import api from openpype.hosts.photoshop import api as photoshop -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name +from openpype.hosts.photoshop.api import get_unique_layer_name stub = photoshop.stub() From ab1b2bdd7d1f82360a2e066fc704581af6b3fd66 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:19:53 +0100 Subject: [PATCH 145/151] moved getting of stub to default photoshop loader class instead of loading it in global scope --- openpype/hosts/photoshop/api/__init__.py | 12 +++--- openpype/hosts/photoshop/api/lib.py | 1 - openpype/hosts/photoshop/api/plugin.py | 9 +++++ .../photoshop/plugins/load/load_image.py | 27 +++++++------ .../plugins/load/load_image_from_sequence.py | 20 ++++------ .../photoshop/plugins/load/load_reference.py | 38 ++++++++++--------- 6 files changed, 60 insertions(+), 47 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index a25dfe7044..17a371f002 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -4,6 +4,8 @@ Anything that isn't defined here is INTERNAL and unreliable for external use. """ +from .launch_logic import stub + from .pipeline import ( ls, list_instances, @@ -13,6 +15,7 @@ from .pipeline import ( containerise ) from .plugin import ( + PhotoshopLoader, get_unique_layer_name ) from .workio import ( @@ -29,9 +32,10 @@ from .lib import ( maintained_visibility ) -from .launch_logic import stub - __all__ = [ + # launch_logic + "stub" + # pipeline "ls", "list_instances", @@ -41,6 +45,7 @@ __all__ = [ "containerise", # Plugin + "PhotoshopLoader", "get_unique_layer_name", # workfiles @@ -54,7 +59,4 @@ __all__ = [ # lib "maintained_selection", "maintained_visibility", - - # launch_logic - "stub" ] diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 509c5d5c48..707cd476c5 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,6 +1,5 @@ import os import sys -import re import contextlib import traceback diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index 74aff06114..c577c67d82 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -1,5 +1,8 @@ import re +import avalon.api +from .launch_logic import stub + def get_unique_layer_name(layers, asset_name, subset_name): """ @@ -24,3 +27,9 @@ def get_unique_layer_name(layers, asset_name, subset_name): occurrences = names.get(name, 0) return "{}_{:0>3d}".format(name, occurrences + 1) + + +class PhotoshopLoader(avalon.api.Loader): + @staticmethod + def get_stub(): + return stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 25f47b0257..3b1cfe9636 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -5,9 +5,7 @@ from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ImageLoader(api.Loader): +class ImageLoader(photoshop.PhotoshopLoader): """Load images Stores the imported asset in a container named after the asset. @@ -17,11 +15,14 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), + context["asset"]["name"], + name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -36,6 +37,8 @@ class ImageLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() + layer = container.pop("layer") context = representation.get("context", {}) @@ -45,9 +48,9 @@ class ImageLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -67,6 +70,8 @@ class ImageLoader(api.Loader): Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() + layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -74,5 +79,5 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): + def import_layer(self, file_name, layer_name, stub): return stub.import_smart_object(file_name, layer_name) diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index bbf4c60242..ab4682e63e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,17 +1,13 @@ import os -from avalon import api from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse -from openpype.lib import Anatomy from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ImageFromSequenceLoader(api.Loader): +class ImageFromSequenceLoader(photoshop.PhotoshopLoader): """ Load specifing image from sequence Used only as quick load of reference file from a sequence. @@ -35,15 +31,16 @@ class ImageFromSequenceLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): if data.get("frame"): - self.fname = os.path.join(os.path.dirname(self.fname), - data["frame"]) + self.fname = os.path.join( + os.path.dirname(self.fname), data["frame"] + ) if not os.path.exists(self.fname): return - stub = photoshop.stub() - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): layer = stub.import_smart_object(self.fname, layer_name) @@ -95,4 +92,3 @@ class ImageFromSequenceLoader(api.Loader): def remove(self, container): """No update possible, not containerized.""" pass - diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0f3c148155..60142d4a1f 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -5,27 +5,26 @@ from avalon import api from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ReferenceLoader(api.Loader): +class ReferenceLoader(photoshop.PhotoshopLoader): """Load reference images - Stores the imported asset in a container named after the asset. + Stores the imported asset in a container named after the asset. - Inheriting from 'load_image' didn't work because of - "Cannot write to closing transport", possible refactor. + Inheriting from 'load_image' didn't work because of + "Cannot write to closing transport", possible refactor. """ families = ["image", "render"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -40,6 +39,7 @@ class ReferenceLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) @@ -49,9 +49,9 @@ class ReferenceLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -66,11 +66,12 @@ class ReferenceLoader(api.Loader): ) def remove(self, container): - """ - Removes element from scene: deletes layer + removes from Headline + """Removes element from scene: deletes layer + removes from Headline + Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -78,6 +79,7 @@ class ReferenceLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): - return stub.import_smart_object(file_name, layer_name, - as_reference=True) + def import_layer(self, file_name, layer_name, stub): + return stub.import_smart_object( + file_name, layer_name, as_reference=True + ) From c2b6cf8714a8caf5d6e49efbb44b386fdcdf713a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:38:32 +0100 Subject: [PATCH 146/151] fixed init file --- openpype/hosts/photoshop/api/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 17a371f002..4cc2aa2c78 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -10,12 +10,13 @@ from .pipeline import ( ls, list_instances, remove_instance, - Creator, install, + uninstall, containerise ) from .plugin import ( PhotoshopLoader, + Creator, get_unique_layer_name ) from .workio import ( @@ -34,18 +35,18 @@ from .lib import ( __all__ = [ # launch_logic - "stub" + "stub", # pipeline "ls", "list_instances", "remove_instance", - "Creator", "install", "containerise", # Plugin "PhotoshopLoader", + "Creator", "get_unique_layer_name", # workfiles From b6a5123210d8f278cbb320f7378bce877c798949 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:38:43 +0100 Subject: [PATCH 147/151] moved Creator to plugin.py --- openpype/hosts/photoshop/api/pipeline.py | 34 ------------------------ openpype/hosts/photoshop/api/plugin.py | 34 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ed7b94e249..25983f2471 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -193,40 +193,6 @@ def _get_stub(): return stub -class Creator(avalon.api.Creator): - """Creator plugin to create instances in Photoshop - - A LayerSet is created to support any number of layers in an instance. If - the selection is used, these layers will be added to the LayerSet. - """ - - def process(self): - # Photoshop can have multiple LayerSets with the same name, which does - # not work with Avalon. - msg = "Instance with name \"{}\" already exists.".format(self.name) - stub = lib.stub() # only after Photoshop is up - for layer in stub.get_layers(): - if self.name.lower() == layer.Name.lower(): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(msg) - msg.exec_() - return False - - # Store selection because adding a group will change selection. - with lib.maintained_selection(): - - # Add selection to group. - if (self.options or {}).get("useSelection"): - group = stub.group_selected_layers(self.name) - else: - group = stub.create_group(self.name) - - stub.imprint(group, self.data) - - return group - - def containerise( name, namespace, layer, context, loader=None, suffix="_CON" ): diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index c577c67d82..e0db67de2c 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -33,3 +33,37 @@ class PhotoshopLoader(avalon.api.Loader): @staticmethod def get_stub(): return stub() + + +class Creator(avalon.api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group From 100ff46421ceb688e7d2e20dec58f43d20f5902b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 11 Jan 2022 13:27:16 +0100 Subject: [PATCH 148/151] OP-2049 - fix frame content for sequence starting with 0 Previously expression didn't trigger as repre.get("frameStart") returned 0 which translated into False --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 1b0b8da2ff..cec2e470b3 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -580,7 +580,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if repre.get("outputName"): representation["context"]["output"] = repre['outputName'] - if sequence_repre and repre.get("frameStart"): + if sequence_repre and repre.get("frameStart") is not None: representation['context']['frame'] = ( dst_padding_exp % int(repre.get("frameStart")) ) From dbf9c6899632c3ec0ed11da1a1d0e17d35a70dc4 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 12 Jan 2022 03:43:49 +0000 Subject: [PATCH 149/151] [Automated] Bump version --- CHANGELOG.md | 13 ++++++++++--- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ab087690..e92c16dc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.8.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) @@ -10,23 +10,29 @@ **🚀 Enhancements** +- Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) +- TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) +- Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) - Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) - Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) +- Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) +- Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) +- Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **🐛 Bug fixes** - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) +- General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) -- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) **Merged pull requests:** @@ -63,7 +69,6 @@ - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) - Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) -- TVPaint: Move implementation to OpenPype [\#2336](https://github.com/pypeclub/OpenPype/pull/2336) **🐛 Bug fixes** @@ -80,7 +85,9 @@ - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) +- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) +- Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) **Merged pull requests:** diff --git a/openpype/version.py b/openpype/version.py index ed0a96d4de..1f005d6952 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.2" +__version__ = "3.8.0-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 0ef447e0be..f9155f05a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.2" # OpenPype +version = "3.8.0-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 87c5eb549786fa2e166dd381fe4b97204b146b48 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 17:55:22 +0100 Subject: [PATCH 150/151] Expose toggle publish plug-in settings for Maya Look Shading Engine Naming --- openpype/settings/defaults/project_settings/maya.json | 5 +++++ .../projects_schema/schemas/schema_maya_publish.json | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b75b0168ec..a756071106 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -166,6 +166,11 @@ "enabled": false, "regex": "(?P.*)_(.*)_SHD" }, + "ValidateShadingEngine": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateAttributes": { "enabled": false, "attributes": {} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 606dd6c2bb..7c9a5a6b46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -72,6 +72,17 @@ ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateShadingEngine", + "label": "Validate Look Shading Engine Naming" + } + ] + }, + { "type": "dict", "collapsible": true, From 6c2204c92d577a9cddc9533b13d16fd2829f1974 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 18:44:33 +0100 Subject: [PATCH 151/151] added ability to hide publish if plugin need it --- openpype/tools/pyblish_pype/window.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index fdd2d80e23..edcf6f53b6 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -909,6 +909,13 @@ class Window(QtWidgets.QDialog): self.tr("Processing"), plugin_item.data(QtCore.Qt.DisplayRole) )) + visibility = True + if hasattr(plugin, "hide_ui_on_process") and plugin.hide_ui_on_process: + visibility = False + + if self.isVisible() != visibility: + self.setVisible(visibility) + def on_plugin_action_menu_requested(self, pos): """The user right-clicked on a plug-in __________