diff --git a/igniter/__init__.py b/igniter/__init__.py index defd45e233..02cba6a483 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -6,9 +6,18 @@ 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 +# 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 + def open_dialog(): """Show Igniter dialog.""" @@ -22,7 +31,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() @@ -43,7 +54,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() @@ -53,9 +66,32 @@ def open_update_window(openpype_version): return version_path +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) + 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/bootstrap_repos.py b/igniter/bootstrap_repos.py index 151597e505..db62cbbe91 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 @@ -60,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 + _installed_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. @@ -232,6 +236,390 @@ 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__'] + + @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 + + @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 + + 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 + if 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 + + 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 + if 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))) + + @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) + + @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_installed_version(cls): + """Get version of OpenPype inside build.""" + 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._installed_version + + @staticmethod + def get_latest_version( + staging: bool = False, + local: bool = None, + remote: bool = None + ) -> OpenPypeVersion: + """Get latest available version. + + 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 local versions if True. + remote (bool, optional): List remote versions if True. + """ + if local is None and remote is None: + local = True + remote = True + + elif local is None and not remote: + local = True + + elif remote is None and not local: + remote = True + + installed_version = OpenPypeVersion.get_installed_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(installed_version) + + if not all_versions: + return None + + all_versions.sort() + return all_versions[-1] + + @classmethod + 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, global_settings) + if not result: + return None + return OpenPypeVersion(version=result) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. @@ -301,16 +689,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. @@ -358,7 +736,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) @@ -734,6 +1112,65 @@ class BootstrapRepos: os.environ["PYTHONPATH"] = os.pathsep.join(paths) + @staticmethod + def find_openpype_version(version, staging): + if isinstance(version, str): + version = OpenPypeVersion(version=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 + ) + zip_version = None + for local_version in local_versions: + if local_version == version: + if local_version.path.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): + installed_version = OpenPypeVersion.get_installed_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(installed_version) + + if not all_versions: + return None + + all_versions.sort() + latest_version = all_versions[-1] + if latest_version == installed_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, 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/message_dialog.py b/igniter/message_dialog.py new file mode 100644 index 0000000000..c8e875cc37 --- /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): + """Simple message dialog with title, message and OK button.""" + 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.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()) diff --git a/igniter/tools.py b/igniter/tools.py index 3e862f5803..735402e9a2 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. @@ -182,6 +187,28 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: return None +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") + if global_settings is None: + 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. @@ -192,3 +219,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" + ) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py new file mode 100644 index 0000000000..e3a4e1fa3e --- /dev/null +++ b/openpype/lib/openpype_version.py @@ -0,0 +1,85 @@ +"""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 inner logic of versions. + +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. +""" + +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_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_installed_version() + return None + + +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(): + """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_expected_studio_version(staging=False): + """Expected production or staging version in studio.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_expected_studio_version(staging) + return None 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/__init__.py b/openpype/settings/entities/__init__.py index ccf2a5993e..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, @@ -106,7 +106,7 @@ from .enum_entity import ( ToolsEnumEntity, TaskTypeEnumEntity, DeadlineUrlEnumEntity, - AnatomyTemplatesEnumEntity + AnatomyTemplatesEnumEntity, ) from .list_entity import ListEntity @@ -122,12 +122,15 @@ from .dict_conditional import ( ) from .anatomy_entities import AnatomyEntity - +from .op_version_entity import ( + ProductionVersionsInputEntity, + StagingVersionsInputEntity +) __all__ = ( "DefaultsNotDefined", "StudioDefaultsNotDefined", - "BaseInvalidValueType", + "BaseInvalidValue", "InvalidValueType", "InvalidKeySymbols", "SchemaMissingFileInfo", @@ -181,5 +184,8 @@ __all__ = ( "DictConditionalEntity", "SyncServerProviders", - "AnatomyEntity" + "AnatomyEntity", + + "ProductionVersionsInputEntity", + "StagingVersionsInputEntity" ) diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index cbc042d29d..582937481a 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 @@ -437,7 +437,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] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 16893747a6..ff32df9262 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -441,6 +441,16 @@ 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 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 diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py new file mode 100644 index 0000000000..6184f7640a --- /dev/null +++ b/openpype/settings/entities/op_version_entity.py @@ -0,0 +1,85 @@ +from openpype.lib.openpype_version import ( + get_remote_versions, + get_OpenPypeVersion, + get_installed_version +) +from .input_entities import TextEntity +from .lib import ( + OverrideState, + NOT_SET +) +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. + + 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 + self.placeholder_text = "Latest" + self.value_hints = [] + + def _get_openpype_versions(self): + """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() + 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 + + super(OpenPypeVersionInput, self).set_override_state( + state, *args, **kwargs + ) + + 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: + 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): + """Entity meant only for global settings to define production version.""" + schema_types = ["production-versions-text"] + + def _get_openpype_versions(self): + versions = get_remote_versions(staging=False, production=True) + versions.append(get_installed_version()) + return sorted(versions) + + +class StagingVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define staging version.""" + schema_types = ["staging-versions-text"] + + def _get_openpype_versions(self): + versions = get_remote_versions(staging=True, production=False) + return sorted(versions) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 51a58a6e27..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", @@ -30,6 +30,23 @@ { "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", + "label": "Production version" + }, + { + "type": "staging-versions-text", + "key": "staging_version", + "label": "Staging version" + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", 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 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 4159fe1676..3e95ece4b9 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 { @@ -1158,6 +1179,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/base.py b/openpype/tools/settings/settings/base.py index 8a420c2447..e271585852 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -32,6 +32,15 @@ class BaseWidget(QtWidgets.QWidget): self.label_widget = None self.create_ui() + @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 + + obj.setProperty(property_name, property_value) + obj.style().polish(obj) + def scroll_to(self, widget): self.category_widget.scroll_to(widget) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index b046085975..adbde00bf1 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -29,6 +29,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 @@ -47,6 +50,7 @@ from .item_widgets import ( BoolWidget, DictImmutableKeysWidget, TextWidget, + OpenPypeVersionText, NumberWidget, RawJsonWidget, EnumeratorWidget, @@ -118,6 +122,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 2e00967a60..22f672da2b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -2,6 +2,10 @@ import json 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, NumberSpinBox, @@ -22,9 +26,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 +379,16 @@ 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): self.focused_in() @@ -406,6 +417,86 @@ class TextWidget(InputWidget): self.entity.set(self.input_value()) +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(self) + info_widget.setObjectName("OpenPypeVersionLabel") + self.content_layout.addWidget(info_widget, 1) + + self._info_widget = info_widget + + def _update_info_widget(self): + value = self.input_value() + + message = "" + tooltip = "" + state = None + if self._is_invalid: + message = "Invalid OpenPype version format" + + elif value == "": + message = "Use latest available version" + tooltip = ( + "Latest version from OpenPype zip repository will be used" + ) + + elif value in self.entity.value_hints: + state = "success" + message = "Version {} will be used".format(value) + + else: + state = "warning" + message = ( + "Version {} not found in listed versions".format(value) + ) + if self.entity.value_hints: + tooltip = "Listed versions: {}".format(", ".join( + ['"{}"'.format(hint) for hint in self.entity.value_hints] + )) + else: + tooltip = "No versions were listed" + + 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() + + class NumberWidget(InputWidget): _slider_widget = None diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 4c7bf87ce8..b5c08ef79b 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 ( @@ -25,13 +26,235 @@ 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() + if self._filter_model.rowCount() == 0: + result.setHeight(0) + + 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()) + + 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(PlaceholderLineEdit): 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() diff --git a/start.py b/start.py index cc6cae547e..b6c14526f9 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 @@ -467,23 +468,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.")) @@ -521,7 +540,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. @@ -646,67 +665,62 @@ 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) + # Collect OpenPype versions + 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 + studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) + + if use_version is not None: + # Specific version is defined + if use_version.lower() == "latest": + # Version says to use latest version + _print("Finding latest version defined by use version") + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) + else: + _print("Finding specified version \"{}\"".format(use_version)) + openpype_version = bootstrap.find_openpype_version( + use_version, use_staging + ) + + if openpype_version is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format( + use_version + ) + ) + + elif studio_version is not None: + # Studio has defined a version to use + _print("Finding studio version \"{}\"".format(studio_version)) + openpype_version = bootstrap.find_openpype_version( + studio_version, use_staging + ) + if openpype_version is None: + raise OpenPypeVersionNotFound(( + "Requested OpenPype version \"{}\" defined by settings" + " was not found." + ).format(studio_version)) + + else: + # Default behavior to use latest version + _print("Finding latest version") + 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. - local_version_str = bootstrap.get_version( - Path(os.environ["OPENPYPE_ROOT"])) - if local_version_str: - local_version = OpenPypeVersion( - version=local_version_str, - path=Path(os.environ["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 = [] - else: - _print("!!! Warning: cannot determine current running version.") - - 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.")) + if installed_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), @@ -714,22 +728,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: @@ -742,12 +740,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) @@ -783,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,47 +795,45 @@ 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() - 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) + # 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 + ) + 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) + + # 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"] = str(version_to_use) + os.environ["OPENPYPE_VERSION"] = use_version 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] - - 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) else: os.environ["OPENPYPE_VERSION"] = local_version version_path = Path(_openpype_root) @@ -889,16 +892,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 # ------------------------------------------------------------------------ @@ -940,7 +933,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} ]") @@ -969,7 +962,6 @@ def boot(): ) sys.exit(1) - if not openpype_path: _print("*** Cannot get OpenPype path from database.") @@ -989,7 +981,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) @@ -1003,6 +995,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}") @@ -1015,7 +1016,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 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):