diff --git a/.gitmodules b/.gitmodules index 28f164726d..e1b0917e9d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,4 +9,4 @@ url = https://github.com/arrow-py/arrow.git [submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"] path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api - url = https://bitbucket.org/ftrack/ftrack-python-api.git + url = https://bitbucket.org/ftrack/ftrack-python-api.git \ No newline at end of file diff --git a/igniter/__init__.py b/igniter/__init__.py index 20bf9be106..defd45e233 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -12,6 +12,9 @@ from .version import __version__ as version def open_dialog(): """Show Igniter dialog.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) from Qt import QtWidgets, QtCore from .install_dialog import InstallDialog @@ -28,8 +31,31 @@ def open_dialog(): return d.result() +def open_update_window(openpype_version): + """Open update window.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) + from Qt import QtWidgets, QtCore + from .update_window import UpdateWindow + + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + + app = QtWidgets.QApplication(sys.argv) + + d = UpdateWindow(version=openpype_version) + d.open() + + app.exec_() + version_path = d.get_version_path() + return version_path + + __all__ = [ "BootstrapRepos", "open_dialog", + "open_update_window", "version" ] diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b49a2f6e7f..f7f35824c8 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -9,6 +9,7 @@ import sys import tempfile from pathlib import Path from typing import Union, Callable, List, Tuple +import hashlib from zipfile import ZipFile, BadZipFile @@ -28,6 +29,25 @@ LOG_WARNING = 1 LOG_ERROR = 3 +def sha256sum(filename): + """Calculate sha256 for content of the file. + + Args: + filename (str): Path to file. + + Returns: + str: hex encoded sha256 + + """ + h = hashlib.sha256() + b = bytearray(128 * 1024) + mv = memoryview(b) + with open(filename, 'rb', buffering=0) as f: + for n in iter(lambda: f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() + + class OpenPypeVersion(semver.VersionInfo): """Class for storing information about OpenPype version. @@ -261,7 +281,8 @@ class BootstrapRepos: self.live_repo_dir = Path(Path(__file__).parent / ".." / "repos") @staticmethod - def get_version_path_from_list(version: str, version_list: list) -> Path: + def get_version_path_from_list( + version: str, version_list: list) -> Union[Path, None]: """Get path for specific version in list of OpenPype versions. Args: @@ -275,6 +296,7 @@ class BootstrapRepos: for v in version_list: if str(v) == version: return v.path + return None @staticmethod def get_local_live_version() -> str: @@ -487,6 +509,7 @@ class BootstrapRepos: openpype_root = openpype_path.resolve() # generate list of filtered paths dir_filter = [openpype_root / f for f in self.openpype_filter] + checksums = [] file: Path for file in openpype_list: @@ -508,12 +531,119 @@ class BootstrapRepos: processed_path = file self._print(f"- processing {processed_path}") - zip_file.write(file, file.resolve().relative_to(openpype_root)) + checksums.append( + ( + sha256sum(file.as_posix()), + file.resolve().relative_to(openpype_root) + ) + ) + zip_file.write( + file, file.resolve().relative_to(openpype_root)) + checksums_str = "" + for c in checksums: + checksums_str += "{}:{}\n".format(c[0], c[1]) + zip_file.writestr("checksums", checksums_str) # test if zip is ok zip_file.testzip() self._progress_callback(100) + def validate_openpype_version(self, path: Path) -> tuple: + """Validate version directory or zip file. + + This will load `checksums` file if present, calculate checksums + of existing files in given path and compare. It will also compare + lists of files together for missing files. + + Args: + path (Path): Path to OpenPype version to validate. + + Returns: + tuple(bool, str): with version validity as first item + and string with reason as second. + + """ + if not path.exists(): + return False, "Path doesn't exist" + + if path.is_file(): + return self._validate_zip(path) + return self._validate_dir(path) + + @staticmethod + def _validate_zip(path: Path) -> tuple: + """Validate content of zip file.""" + with ZipFile(path, "r") as zip_file: + # read checksums + try: + checksums_data = str(zip_file.read("checksums")) + except IOError: + # FIXME: This should be set to False sometimes in the future + return True, "Cannot read checksums for archive." + + # split it to the list of tuples + checksums = [ + tuple(line.split(":")) + for line in checksums_data.split("\n") if line + ] + + # calculate and compare checksums in the zip file + for file in checksums: + h = hashlib.sha256() + try: + h.update(zip_file.read(file[1])) + except FileNotFoundError: + return False, f"Missing file [ {file[1]} ]" + if h.hexdigest() != file[0]: + return False, f"Invalid checksum on {file[1]}" + + # get list of files in zip minus `checksums` file itself + # and turn in to set to compare against list of files + # from checksum file. If difference exists, something is + # wrong + files_in_zip = zip_file.namelist() + files_in_zip.remove("checksums") + files_in_zip = set(files_in_zip) + files_in_checksum = set([file[1] for file in checksums]) + diff = files_in_zip.difference(files_in_checksum) + if diff: + return False, f"Missing files {diff}" + + return True, "All ok" + + @staticmethod + def _validate_dir(path: Path) -> tuple: + checksums_file = Path(path / "checksums") + if not checksums_file.exists(): + # FIXME: This should be set to False sometimes in the future + return True, "Cannot read checksums for archive." + checksums_data = checksums_file.read_text() + checksums = [ + tuple(line.split(":")) + for line in checksums_data.split("\n") if line + ] + files_in_dir = [ + file.relative_to(path).as_posix() + for file in path.iterdir() if file.is_file() + ] + files_in_dir.remove("checksums") + files_in_dir = set(files_in_dir) + files_in_checksum = set([file[1] for file in checksums]) + + for file in checksums: + try: + current = sha256sum((path / file[1]).as_posix()) + except FileNotFoundError: + return False, f"Missing file [ {file[1]} ]" + + if file[0] != current: + return False, f"Invalid checksum on {file[1]}" + diff = files_in_dir.difference(files_in_checksum) + if diff: + return False, f"Missing files {diff}" + + return True, "All ok" + @staticmethod def add_paths_from_archive(archive: Path) -> None: """Add first-level directory and 'repos' as paths to :mod:`sys.path`. @@ -837,6 +967,7 @@ class BootstrapRepos: # test if destination directory already exist, if so lets delete it. if destination.exists() and force: + self._print("removing existing directory") try: shutil.rmtree(destination) except OSError as e: @@ -846,6 +977,7 @@ class BootstrapRepos: raise OpenPypeVersionIOError( f"cannot remove existing {destination}") from e elif destination.exists() and not force: + self._print("destination directory already exists") raise OpenPypeVersionExists(f"{destination} already exist.") else: # create destination parent directories even if they don't exist. @@ -855,6 +987,7 @@ class BootstrapRepos: if openpype_version.path.is_dir(): # create zip inside temporary directory. self._print("Creating zip from directory ...") + self._progress_callback(0) with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ Path(temp_dir) / f"openpype-v{openpype_version}.zip" @@ -880,13 +1013,16 @@ class BootstrapRepos: raise OpenPypeVersionInvalid("Invalid file format") if not self.is_inside_user_data(openpype_version.path): + self._progress_callback(35) openpype_version.path = self._copy_zip( openpype_version.path, destination) # extract zip there self._print("extracting zip to destination ...") with ZipFile(openpype_version.path, "r") as zip_ref: + self._progress_callback(75) zip_ref.extractall(destination) + self._progress_callback(100) return destination diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 1ec8cc6768..1fe67e3397 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -14,21 +14,13 @@ from .tools import ( validate_mongo_connection, get_openpype_path_from_db ) + +from .nice_progress_bar import NiceProgressBar from .user_settings import OpenPypeSecureRegistry +from .tools import load_stylesheet from .version import __version__ -def load_stylesheet(): - stylesheet_path = os.path.join( - os.path.dirname(__file__), - "stylesheet.css" - ) - with open(stylesheet_path, "r") as file_stream: - stylesheet = file_stream.read() - - return stylesheet - - class ButtonWithOptions(QtWidgets.QFrame): option_clicked = QtCore.Signal(str) @@ -91,25 +83,6 @@ class ButtonWithOptions(QtWidgets.QFrame): self.option_clicked.emit(self._default_value) -class NiceProgressBar(QtWidgets.QProgressBar): - def __init__(self, parent=None): - super(NiceProgressBar, self).__init__(parent) - self._real_value = 0 - - def setValue(self, value): - self._real_value = value - if value != 0 and value < 11: - value = 11 - - super(NiceProgressBar, self).setValue(value) - - def value(self): - return self._real_value - - def text(self): - return "{} %".format(self._real_value) - - class ConsoleWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(ConsoleWidget, self).__init__(parent) diff --git a/igniter/nice_progress_bar.py b/igniter/nice_progress_bar.py new file mode 100644 index 0000000000..47d695a101 --- /dev/null +++ b/igniter/nice_progress_bar.py @@ -0,0 +1,20 @@ +from Qt import QtCore, QtGui, QtWidgets # noqa + + +class NiceProgressBar(QtWidgets.QProgressBar): + def __init__(self, parent=None): + super(NiceProgressBar, self).__init__(parent) + self._real_value = 0 + + def setValue(self, value): + self._real_value = value + if value != 0 and value < 11: + value = 11 + + super(NiceProgressBar, self).setValue(value) + + def value(self): + return self._real_value + + def text(self): + return "{} %".format(self._real_value) diff --git a/igniter/tools.py b/igniter/tools.py index 529d535c25..c934289064 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -248,3 +248,15 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: if os.path.exists(path): return path return None + + +def load_stylesheet() -> str: + """Load css style sheet. + + Returns: + str: content of the stylesheet + + """ + stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" + + return stylesheet_path.read_text() diff --git a/igniter/update_thread.py b/igniter/update_thread.py new file mode 100644 index 0000000000..f4fc729faf --- /dev/null +++ b/igniter/update_thread.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Working thread for update.""" +from Qt.QtCore import QThread, Signal, QObject # noqa + +from .bootstrap_repos import ( + BootstrapRepos, + OpenPypeVersion +) + + +class UpdateThread(QThread): + """Install Worker thread. + + This class takes care of finding OpenPype version on user entered path + (or loading this path from database). If nothing is entered by user, + OpenPype will create its zip files from repositories that comes with it. + + If path contains plain repositories, they are zipped and installed to + user data dir. + + """ + progress = Signal(int) + message = Signal((str, bool)) + + def __init__(self, parent=None): + self._result = None + self._openpype_version = None + QThread.__init__(self, parent) + + def set_version(self, openpype_version: OpenPypeVersion): + self._openpype_version = openpype_version + + def result(self): + """Result of finished installation.""" + return self._result + + def _set_result(self, value): + if self._result is not None: + raise AssertionError("BUG: Result was set more than once!") + self._result = value + + def run(self): + """Thread entry point. + + Using :class:`BootstrapRepos` to either install OpenPype as zip files + or copy them from location specified by user or retrieved from + database. + """ + bs = BootstrapRepos( + progress_callback=self.set_progress, message=self.message) + version_path = bs.install_version(self._openpype_version) + self._set_result(version_path) + + def set_progress(self, progress: int) -> None: + """Helper to set progress bar. + + Args: + progress (int): Progress in percents. + + """ + self.progress.emit(progress) diff --git a/igniter/update_window.py b/igniter/update_window.py new file mode 100644 index 0000000000..d7908c240b --- /dev/null +++ b/igniter/update_window.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""Progress window to show when OpenPype is updating/installing locally.""" +import os +from .update_thread import UpdateThread +from Qt import QtCore, QtGui, QtWidgets # noqa +from .bootstrap_repos import OpenPypeVersion +from .nice_progress_bar import NiceProgressBar +from .tools import load_stylesheet + + +class UpdateWindow(QtWidgets.QDialog): + """OpenPype update window.""" + + _width = 500 + _height = 100 + + def __init__(self, version: OpenPypeVersion, parent=None): + super(UpdateWindow, self).__init__(parent) + self._openpype_version = version + self._result_version_path = None + + self.setWindowTitle( + f"OpenPype is updating ..." + ) + self.setModal(True) + self.setWindowFlags( + QtCore.Qt.WindowMinimizeButtonHint + ) + + 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) + for filename in os.listdir(poppins_font_path): + if os.path.splitext(filename)[1] == ".ttf": + QtGui.QFontDatabase.addApplicationFont(filename) + + # Load logo + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + # Set logo as icon of window + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + self._pixmap_openpype_logo = pixmap_openpype_logo + + self._update_thread = None + + self.resize(QtCore.QSize(self._width, self._height)) + self._init_ui() + + # Set stylesheet + self.setStyleSheet(load_stylesheet()) + self._run_update() + + def _init_ui(self): + + # Main info + # -------------------------------------------------------------------- + main_label = QtWidgets.QLabel( + f"OpenPype is updating to {self._openpype_version}", self) + main_label.setWordWrap(True) + main_label.setObjectName("MainLabel") + + # Progress bar + # -------------------------------------------------------------------- + progress_bar = NiceProgressBar(self) + progress_bar.setAlignment(QtCore.Qt.AlignCenter) + progress_bar.setTextVisible(False) + + # add all to main + main = QtWidgets.QVBoxLayout(self) + main.addSpacing(15) + main.addWidget(main_label, 0) + main.addSpacing(15) + main.addWidget(progress_bar, 0) + main.addSpacing(15) + + self._progress_bar = progress_bar + + def _run_update(self): + """Start install process. + + This will once again validate entered path and mongo if ok, start + working thread that will do actual job. + """ + # Check if install thread is not already running + if self._update_thread and self._update_thread.isRunning(): + return + self._progress_bar.setRange(0, 0) + update_thread = UpdateThread(self) + update_thread.set_version(self._openpype_version) + update_thread.message.connect(self.update_console) + update_thread.progress.connect(self._update_progress) + update_thread.finished.connect(self._installation_finished) + + self._update_thread = update_thread + + update_thread.start() + + def get_version_path(self): + return self._result_version_path + + def _installation_finished(self): + status = self._update_thread.result() + self._result_version_path = status + self._progress_bar.setRange(0, 1) + self._update_progress(100) + QtWidgets.QApplication.processEvents() + self.done(0) + + def _update_progress(self, progress: int): + # not updating progress as we are not able to determine it + # correctly now. Progress bar is set to un-deterministic mode + # until we are able to get progress in better way. + """ + self._progress_bar.setRange(0, 0) + self._progress_bar.setValue(progress) + text_visible = self._progress_bar.isTextVisible() + if progress == 0: + if text_visible: + self._progress_bar.setTextVisible(False) + elif not text_visible: + self._progress_bar.setTextVisible(True) + """ + return + + def update_console(self, msg: str, error: bool = False) -> None: + """Display message in console. + + Args: + msg (str): message. + error (bool): if True, print it red. + """ + print(msg) diff --git a/openpype/cli.py b/openpype/cli.py index c446d5e443..18cc1c63cd 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -18,6 +18,8 @@ from .pype_commands import PypeCommands @click.option("--list-versions", is_flag=True, expose_value=False, help=("list all detected versions. Use With `--use-staging " "to list staging versions.")) +@click.option("--validate-version", expose_value=False, + help="validate given version integrity") def main(ctx): """Pype is main command serving as entry point to pipeline system. diff --git a/start.py b/start.py index 6473a926d0..00f9a50cbb 100644 --- a/start.py +++ b/start.py @@ -179,6 +179,12 @@ else: ssl_cert_file = certifi.where() os.environ["SSL_CERT_FILE"] = ssl_cert_file +if "--headless" in sys.argv: + os.environ["OPENPYPE_HEADLESS_MODE"] = "1" + sys.argv.remove("--headless") +else: + if os.getenv("OPENPYPE_HEADLESS_MODE") != "1": + os.environ.pop("OPENPYPE_HEADLESS_MODE", None) import igniter # noqa: E402 from igniter import BootstrapRepos # noqa: E402 @@ -343,7 +349,7 @@ def _process_arguments() -> tuple: # check for `--use-version=3.0.0` argument and `--use-staging` use_version = None use_staging = False - print_versions = False + commands = [] for arg in sys.argv: if arg == "--use-version": _print("!!! Please use option --use-version like:") @@ -366,17 +372,38 @@ def _process_arguments() -> tuple: " proper version string.")) sys.exit(1) + if arg == "--validate-version": + _print("!!! Please use option --validate-version like:") + _print(" --validate-version=3.0.0") + sys.exit(1) + + if arg.startswith("--validate-version="): + m = re.search( + r"--validate-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) + if m and m.group('version'): + use_version = m.group('version') + sys.argv.remove(arg) + commands.append("validate") + else: + _print("!!! Requested version isn't in correct format.") + _print((" Use --list-versions to find out" + " proper version string.")) + sys.exit(1) + if "--use-staging" in sys.argv: use_staging = True sys.argv.remove("--use-staging") if "--list-versions" in sys.argv: - print_versions = True + commands.append("print_versions") sys.argv.remove("--list-versions") # handle igniter # this is helper to run igniter before anything else if "igniter" in sys.argv: + 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() @@ -389,7 +416,7 @@ def _process_arguments() -> tuple: sys.argv.pop(idx) sys.argv.insert(idx, "tray") - return use_version, use_staging, print_versions + return use_version, use_staging, commands def _determine_mongodb() -> str: @@ -424,6 +451,11 @@ def _determine_mongodb() -> str: if not openpype_mongo: _print("*** No DB connection string specified.") + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + _print( + "!!! Please use `OPENPYPE_MONGO` to specify server address.") + sys.exit(1) _print("--- launching setup UI ...") result = igniter.open_dialog() @@ -527,6 +559,9 @@ def _find_frozen_openpype(use_version: str = None, 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() @@ -590,8 +625,16 @@ def _find_frozen_openpype(use_version: str = None, if not is_inside: # install latest version to user data dir - version_path = bootstrap.install_version( - openpype_version, force=True) + if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1": + import igniter + version_path = igniter.open_update_window(openpype_version) + else: + version_path = bootstrap.install_version( + openpype_version, force=True) + + openpype_version.path = version_path + _initialize_environment(openpype_version) + return openpype_version.path if openpype_version.path.is_file(): _print(">>> Extracting zip file ...") @@ -738,7 +781,7 @@ def boot(): # Process arguments # ------------------------------------------------------------------------ - use_version, use_staging, print_versions = _process_arguments() + use_version, use_staging, commands = _process_arguments() if os.getenv("OPENPYPE_VERSION"): if use_version: @@ -766,13 +809,47 @@ def boot(): # Get openpype path from database and set it to environment so openpype can # find its versions there and bootstrap them. openpype_path = get_openpype_path_from_db(openpype_mongo) + + if getattr(sys, 'frozen', False): + local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) + else: + local_version = bootstrap.get_local_live_version() + + if "validate" in commands: + _print(f">>> Validating version [ {use_version} ]") + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=True) + openpype_versions += bootstrap.find_openpype(include_zips=True, + staging=False) + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if not found: + _print(f"!!! Version [ {use_version} ] not found.") + list_versions(openpype_versions, local_version) + sys.exit(1) + + # print result + result = bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions)) + + _print("{}{}".format( + ">>> " if result[0] else "!!! ", + bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions) + )[1]) + ) + sys.exit(1) + + if not openpype_path: _print("*** Cannot get OpenPype path from database.") if not os.getenv("OPENPYPE_PATH") and openpype_path: os.environ["OPENPYPE_PATH"] = openpype_path - if print_versions: + if "print_versions" in commands: if not use_staging: _print("--- This will list only non-staging versions detected.") _print(" To see staging versions, use --use-staging argument.") @@ -803,6 +880,13 @@ def boot(): # no version to run _print(f"!!! {e}") sys.exit(1) + # validate version + _print(f">>> Validating version [ {str(version_path)} ]") + result = bootstrap.validate_openpype_version(version_path) + if not result[0]: + _print(f"!!! Invalid version: {result[1]}") + sys.exit(1) + _print(f"--- version is valid") else: version_path = _bootstrap_from_code(use_version, use_staging) diff --git a/website/docs/admin_openpype_commands.md b/website/docs/admin_openpype_commands.md index 1a91e2e7fe..d6ccc883b0 100644 --- a/website/docs/admin_openpype_commands.md +++ b/website/docs/admin_openpype_commands.md @@ -18,11 +18,14 @@ Running OpenPype without any commands will default to `tray`. ```shell openpype_console --use-version=3.0.0-foo+bar ``` +`--headless` - to run OpenPype in headless mode (without using graphical UI) `--use-staging` - to use staging versions of OpenPype. `--list-versions [--use-staging]` - to list available versions. +`--validate-version` to validate integrity of given version + For more information [see here](admin_use#run-openpype). ## Commands diff --git a/website/docs/admin_use.md b/website/docs/admin_use.md index 4ad08a0174..178241ad19 100644 --- a/website/docs/admin_use.md +++ b/website/docs/admin_use.md @@ -56,6 +56,19 @@ openpype_console --list-versions You can add `--use-staging` to list staging versions. ::: +If you want to validate integrity of some available version, you can use: + +```shell +openpype_console --validate-version=3.3.0 +``` + +This will go through the version and validate file content against sha 256 hashes +stored in `checksums` file. + +:::tip Headless mode +Add `--headless` to run OpenPype without graphical UI (useful on server or on automated tasks, etc.) +::: + ### Details When you run OpenPype from executable, few check are made: