diff --git a/igniter/__main__.py b/igniter/__main__.py index b891ade37e..adabb02357 100644 --- a/igniter/__main__.py +++ b/igniter/__main__.py @@ -6,7 +6,6 @@ from Qt import QtWidgets from .install_dialog import InstallDialog -print(__file__) app = QtWidgets.QApplication(sys.argv) d = InstallDialog() d.show() diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 1c383558ab..8694e692f1 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1,6 +1,121 @@ # -*- coding: utf-8 -*- -"""Bootstrap Pype repositories.""" +"""Bootstrap Pype repositories. + +Attrbutes: + data_dir (str): platform dependent path where pype expects its + repositories and configuration files. +""" +import sys +import os +import logging as log +import shutil +import tempfile +from typing import Union +from zipfile import ZipFile + +from appdirs import user_data_dir +from version import __version__ -def check_user_repos(): - pass +class BootstrapRepos(): + """Class for bootstrapping local Pype installation. + + Attributes: + data_dir (str): local Pype installation directory. + live_repo_dir (str): path to repos directory if running live, + otherwise `None`. + """ + _vendor = "pypeclub" + _app = "pype" + + def __init__(self): + self._log = log.getLogger(str(__class__)) + self.data_dir = user_data_dir(self._app, self._vendor) + if getattr(sys, 'frozen', False): + self.live_repo_dir = None + else: + self.live_repo_dir = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "repos" + ) + ) + + def get_local_version(self) -> str: + """Get version of local Pype.""" + return __version__ + + def install_live_repos(self) -> Union[str, None]: + """Copy zip created from local repositories to user data dir. + + Returns: + str: path of installed repository file. + """ + # create zip from repositories + local_version = self.get_local_version() + repo_dir = self.live_repo_dir + with tempfile.TemporaryDirectory() as temp_dir: + temp_zip = os.path.join( + str(temp_dir), + f"pype-repositories-v{local_version}.zip" + ) + self._log.info(f"creating zip: {temp_zip}") + # shutil.make_archive(temp_zip, "zip", repo_dir) + self._create_pype_zip(temp_zip, repo_dir) + if not os.path.exists(temp_zip): + self._log.error("make archive failed.") + return None + shutil.move(temp_zip, self.data_dir) + return os.path.join(self.data_dir, os.path.basename(temp_zip)) + + def _create_pype_zip( + self, zip_path: str, dir: str, include_pype=True) -> None: + """Pack repositories and Pype into zip. + + We are using `zipfile` instead :meth:`shutil.make_archive()` to later + implement file filter to skip git related stuff to make it into + archive. + + Todo: + Implement file filter + + Args: + zip_path (str): path to zip file. + dir: repo directories to inlcude. + include_pype (bool): add Pype module itelf. + """ + with ZipFile(zip_path, "w") as zip: + for root, dirs, files in os.walk(dir): + for file in files: + zip.write( + os.path.relpath(os.path.join(root, file), + os.path.join(dir, '..')), + os.path.relpath(os.path.join(root, file), + os.path.join(dir)) + ) + # add pype itself + if include_pype: + for root, dirs, files in os.walk("pype"): + for file in files: + zip.write( + os.path.relpath(os.path.join(root, file), + os.path.join('pype', '..')), + os.path.join( + 'pype', + os.path.relpath(os.path.join(root, file), + os.path.join('pype', '..'))) + ) + zip.testzip() + + def add_paths_from_archive(self, archive: str) -> None: + """Add first-level directories as paths to sys.path. + + This will enable Python to import modules is second-level directories + in zip file. + + Args: + archive (str): path to archive. + + """ + pass diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 6b46a73e95..5863ed4209 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -4,6 +4,8 @@ import sys import os from Qt import QtCore, QtGui, QtWidgets +from .install_thread import InstallThread + class InstallDialog(QtWidgets.QDialog): _size_w = 400 @@ -77,20 +79,20 @@ class InstallDialog(QtWidgets.QDialog): "border: 1px solid rgb(32, 32, 32);") ) - self.btn_select = QtWidgets.QPushButton("Select") - self.btn_select.setToolTip( + self._btn_select = QtWidgets.QPushButton("Select") + self._btn_select.setToolTip( "Select Pype repository" ) - self.btn_select.setStyleSheet( + self._btn_select.setStyleSheet( ("color: rgb(64, 64, 64);" "background-color: rgb(72, 200, 150);" "padding: 0.5em;") ) - self.btn_select.setMaximumSize(100, 140) - self.btn_select.clicked.connect(self._on_select_clicked) + self._btn_select.setMaximumSize(100, 140) + self._btn_select.clicked.connect(self._on_select_clicked) input_layout.addWidget(self.user_input) - input_layout.addWidget(self.btn_select) + input_layout.addWidget(self._btn_select) # Bottom button bar # -------------------------------------------------------------------- @@ -105,31 +107,31 @@ class InstallDialog(QtWidgets.QDialog): pype_logo_label.setPixmap(pype_logo) pype_logo_label.setContentsMargins(10, 0, 0, 10) - ok_button = QtWidgets.QPushButton("OK") - ok_button.setStyleSheet( + self._ok_button = QtWidgets.QPushButton("OK") + self._ok_button.setStyleSheet( ("color: rgb(64, 64, 64);" "background-color: rgb(72, 200, 150);" "padding: 0.5em;") ) - ok_button.setMinimumSize(64, 24) - ok_button.setToolTip("Save and continue") - ok_button.clicked.connect(self._on_ok_clicked) + self._ok_button.setMinimumSize(64, 24) + self._ok_button.setToolTip("Save and continue") + self._ok_button.clicked.connect(self._on_ok_clicked) - exit_button = QtWidgets.QPushButton("Exit") - exit_button.setStyleSheet( + self._exit_button = QtWidgets.QPushButton("Exit") + self._exit_button.setStyleSheet( ("color: rgb(64, 64, 64);" "background-color: rgb(128, 128, 128);" "padding: 0.5em;") ) - exit_button.setMinimumSize(64, 24) - exit_button.setToolTip("Exit without saving") - exit_button.clicked.connect(self._on_exit_clicked) + self._exit_button.setMinimumSize(64, 24) + self._exit_button.setToolTip("Exit without saving") + self._exit_button.clicked.connect(self._on_exit_clicked) bottom_layout.setContentsMargins(0, 10, 0, 0) bottom_layout.addWidget(pype_logo_label) bottom_layout.addStretch(1) - bottom_layout.addWidget(ok_button) - bottom_layout.addWidget(exit_button) + bottom_layout.addWidget(self._ok_button) + bottom_layout.addWidget(self._exit_button) bottom_widget.setLayout(bottom_layout) bottom_widget.setStyleSheet("background-color: rgb(32, 32, 32);") @@ -138,15 +140,53 @@ class InstallDialog(QtWidgets.QDialog): # -------------------------------------------------------------------- self._status_label = QtWidgets.QLabel() self._status_label.setContentsMargins(0, 10, 0, 10) - self._status_label.setStyleSheet("color: rgb(72, 200, 150);") + self._status_label.setStyleSheet("color: rgb(61, 115, 97);") + + self._status_box = QtWidgets.QPlainTextEdit() + self._status_box.setReadOnly(True) + self._status_box.setStyleSheet( + """QPlainTextEdit { + background-color: rgb(32, 32, 32); + color: rgb(72, 200, 150); + font-family: Courier; + font-size: .3em;} + QScrollBar:vertical { + border: 1px solid rgb(61, 115, 97); + background: #000; + width:5px; + margin: 0px 0px 0px 0px; + } + QScrollBar::handle:vertical { + background: rgb(72, 200, 150); + min-height: 0px; + } + QScrollBar::sub-page:vertical { + background: rgb(31, 62, 50); + } + QScrollBar::add-page:vertical { + background: rgb(31, 62, 50); + } + QScrollBar::add-line:vertical { + background: rgb(72, 200, 150); + height: 0px; + subcontrol-position: bottom; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical { + background: rgb(72, 200, 150); + height: 0 px; + subcontrol-position: top; + subcontrol-origin: margin; + } + """ + ) # add all to main - main.addWidget(self.main_label) main.addWidget(self.pype_path_label) main.addLayout(input_layout) main.addStretch(1) - main.addWidget(self._status_label) + main.addWidget(self._status_box) main.addWidget(bottom_widget) self.setLayout(main) @@ -161,16 +201,53 @@ class InstallDialog(QtWidgets.QDialog): self.user_input.setText(fname) def _on_ok_clicked(self): - if not self._path: - pass + self._disable_buttons() + self._install_thread = InstallThread(self) + self._install_thread.message.connect(self._update_console) + self._install_thread.finished.connect(self._enable_buttons) + self._install_thread.set_path(self._path) + self._install_thread.start() + + def _update_console(self, msg): + self._status_box.appendPlainText(msg) def _on_exit_clicked(self): self.close() - def _path_changed(self, path): + def _path_changed(self, path: str) -> None: self._path = path self._status_label.setText(f"selected {path}") + def _update_status(self, msg: str, error: bool = False) -> None: + """Display message. + + Args: + msg (str): message. + error (bool): if True, print it red. + """ + if not error: + self._status_label.setStyleSheet("color: rgb(72, 200, 150);") + else: + self._status_label.setStyleSheet("color: rgb(189, 54, 32);") + self._status_label.setText(msg) + + def _disable_buttons(self): + self._btn_select.setEnabled(False) + self._exit_button.setEnabled(False) + self._ok_button.setEnabled(False) + self._controls_disabled(True) + + def _enable_buttons(self): + self._btn_select.setEnabled(True) + self._exit_button.setEnabled(True) + self._ok_button.setEnabled(True) + self._controls_disabled(False) + + def closeEvent(self, event): + if self._controls_disabled: + return event.ignore() + return super(InstallDialog, self).closeEvent(event) + if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) diff --git a/igniter/install_thread.py b/igniter/install_thread.py new file mode 100644 index 0000000000..fc1865001c --- /dev/null +++ b/igniter/install_thread.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""Working thread for installer.""" +import sys +from Qt.QtCore import QThread, Signal + +from .bootstrap_repos import BootstrapRepos + + +class InstallThread(QThread): + + message = Signal(str) + _path = None + + def __init__(self, parent=None): + QThread.__init__(self, parent) + + def run(self): + self.message.emit("Installing Pype ...") + bs = BootstrapRepos() + local_version = bs.get_local_version() + + if getattr(sys, "freeze", None): + # copy frozen repo zip + pass + else: + # we are running from live code. + # zip content of `repos`, copy it to user data dir and append + # version to it. + if not self._path: + self.message.emit( + f"We will use local Pype version {local_version}") + repo_file = bs.install_live_repos() + if not repo_file: + self.message.emit(f"!!! install failed - {repo_file}") + self.message.emit(f"installed as {repo_file}") + + def set_path(self, path: str) -> None: + self._path = path diff --git a/pype.py b/pype.py index 57c2fa8b67..1ad70c3963 100644 --- a/pype.py +++ b/pype.py @@ -1,11 +1,25 @@ # -*- coding: utf-8 -*- """Main entry point for Pype command.""" +import sys +import os +import traceback + +from appdirs import user_data_dir + from pype import cli from pype.lib import terminal as t -import sys -import traceback from version import __version__ + +vendor = "pypeclub" +app = "pype" +pype_dir = user_data_dir(app, vendor) +repo_zip = os.path.join(pype_dir, f"pype-repositories-v{__version__}.zip") +if getattr(sys, 'frozen', False): + datadir = os.path.dirname(sys.executable) +else: + datadir = os.path.dirname(__file__) + art = r""" ____________ /\ __ \ @@ -19,6 +33,9 @@ art = r""" print(art) t.echo(f"*** Pype [{__version__}] --------------------") +t.echo(">>> Validating installation ...") + +t.echo(sys.executable) try: cli.main(obj={}, prog_name="pype") except Exception: diff --git a/requirements.txt b/requirements.txt index 3cdc32aa3d..a523a2df88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +appdirs wheel certifi Click diff --git a/run_tests.bat b/run_tests.bat new file mode 100644 index 0000000000..48ccf9e7cc --- /dev/null +++ b/run_tests.bat @@ -0,0 +1,2 @@ +set PYTHONPATH=".;%PYTHONPATH%" +pytest -x --capture=sys --print -W ignore::DeprecationWarning ./tests diff --git a/setup.py b/setup.py index c25c0dd6db..fb8fb13f46 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,7 @@ from version import __version__ install_requires = [ + "appdirs" "clique", "jsonschema", "OpenTimelineIO", diff --git a/tests/igniter/test_bootstrap_repos.py b/tests/igniter/test_bootstrap_repos.py new file mode 100644 index 0000000000..caf42ca656 --- /dev/null +++ b/tests/igniter/test_bootstrap_repos.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +"""Test suite for repos bootstrapping (install).""" +import os +import pytest +from igniter.bootstrap_repos import BootstrapRepos + + +@pytest.fixture +def fix_bootrap(tmp_path_factory): + bs = BootstrapRepos() + bs.live_repo_dir = os.path.abspath('repos') + session_temp = tmp_path_factory.mktemp('test_bootstrap') + bs.data_dir = session_temp + return bs + + +def test_install_live_repos(fix_bootrap, printer): + printer(f"repo: {fix_bootrap.live_repo_dir}") + printer(f"data: {fix_bootrap.data_dir}") + rf = fix_bootrap.install_live_repos() + assert os.path.exists(rf), "zip archive was not created"